github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/pkg/platform/runtime/envdef/environment.go (about)

     1  package envdef
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strings"
     9  
    10  	"github.com/ActiveState/cli/internal/errs"
    11  	"github.com/ActiveState/cli/internal/osutils"
    12  	"github.com/thoas/go-funk"
    13  
    14  	"github.com/ActiveState/cli/internal/fileutils"
    15  	"github.com/ActiveState/cli/internal/locale"
    16  )
    17  
    18  // EnvironmentDefinition provides all the information needed to set up an
    19  // environment in which the packaged artifact contents can be used.
    20  type EnvironmentDefinition struct {
    21  	// Env is a list of environment variables to be set
    22  	Env []EnvironmentVariable `json:"env"`
    23  
    24  	// Transforms is a list of file transformations
    25  	Transforms []FileTransform `json:"file_transforms"`
    26  
    27  	// InstallDir is the directory (inside the artifact tarball) that needs to be installed on the user's computer
    28  	InstallDir string `json:"installdir"`
    29  }
    30  
    31  // EnvironmentVariable defines a single environment variable and its values
    32  type EnvironmentVariable struct {
    33  	Name      string       `json:"env_name"`
    34  	Values    []string     `json:"values"`
    35  	Join      VariableJoin `json:"join"`
    36  	Inherit   bool         `json:"inherit"`
    37  	Separator string       `json:"separator"`
    38  }
    39  
    40  // VariableJoin defines a strategy to join environment variables together
    41  type VariableJoin int
    42  
    43  const (
    44  	// Prepend indicates that new variables should be prepended
    45  	Prepend VariableJoin = iota
    46  	// Append indicates that new variables should be prepended
    47  	Append
    48  	// Disallowed indicates that there must be only one value for an environment variable
    49  	Disallowed
    50  )
    51  
    52  // MarshalText marshals a join directive for environment variables
    53  func (j VariableJoin) MarshalText() ([]byte, error) {
    54  	var res string
    55  	switch j {
    56  	default:
    57  		res = "prepend"
    58  	case Append:
    59  		res = "append"
    60  	case Disallowed:
    61  		res = "disallowed"
    62  	}
    63  	return []byte(res), nil
    64  }
    65  
    66  // UnmarshalText un-marshals a join directive for environment variables
    67  func (j *VariableJoin) UnmarshalText(text []byte) error {
    68  	switch string(text) {
    69  	case "prepend":
    70  		*j = Prepend
    71  	case "append":
    72  		*j = Append
    73  	case "disallowed":
    74  		*j = Disallowed
    75  	default:
    76  		return fmt.Errorf("Invalid join directive %s", string(text))
    77  	}
    78  	return nil
    79  }
    80  
    81  // UnmarshalJSON unmarshals an environment variable
    82  // It sets default values for Inherit, Join and Separator if they are not specified
    83  func (ev *EnvironmentVariable) UnmarshalJSON(data []byte) error {
    84  	type evAlias EnvironmentVariable
    85  	v := &evAlias{
    86  		Inherit:   true,
    87  		Separator: ":",
    88  		Join:      Prepend,
    89  	}
    90  
    91  	err := json.Unmarshal(data, v)
    92  	if err != nil {
    93  		return err
    94  	}
    95  
    96  	*ev = EnvironmentVariable(*v)
    97  	return nil
    98  }
    99  
   100  // NewEnvironmentDefinition returns an environment definition unmarshaled from a
   101  // file
   102  func NewEnvironmentDefinition(fp string) (*EnvironmentDefinition, error) {
   103  	blob, err := os.ReadFile(fp)
   104  	if err != nil {
   105  		return nil, locale.WrapError(err, "envdef_file_not_found", "", fp)
   106  	}
   107  	ed := &EnvironmentDefinition{}
   108  	err = json.Unmarshal(blob, ed)
   109  	if err != nil {
   110  		return nil, locale.WrapError(err, "envdef_unmarshal_error", "", fp)
   111  	}
   112  	return ed, nil
   113  }
   114  
   115  // WriteFile marshals an environment definition to a file
   116  func (ed *EnvironmentDefinition) WriteFile(filepath string) error {
   117  	blob, err := ed.Marshal()
   118  	if err != nil {
   119  		return err
   120  	}
   121  	return os.WriteFile(filepath, blob, 0666)
   122  }
   123  
   124  // WriteFile marshals an environment definition to a file
   125  func (ed *EnvironmentDefinition) Marshal() ([]byte, error) {
   126  	blob, err := json.MarshalIndent(ed, "", "  ")
   127  	if err != nil {
   128  		return []byte(""), err
   129  	}
   130  	return blob, nil
   131  }
   132  
   133  // ExpandVariables expands substitution strings specified in the environment variable values.
   134  // Right now, the only valid substition string is `${INSTALLDIR}` which is being replaced
   135  // with the base of the installation directory for a given project
   136  func (ed *EnvironmentDefinition) ExpandVariables(constants Constants) *EnvironmentDefinition {
   137  	res := ed
   138  	for k, v := range constants {
   139  		res = ed.ReplaceString(fmt.Sprintf("${%s}", k), v)
   140  	}
   141  	return res
   142  }
   143  
   144  // ReplaceString replaces the string `from` with its `replacement` value
   145  // in every environment variable value
   146  func (ed *EnvironmentDefinition) ReplaceString(from string, replacement string) *EnvironmentDefinition {
   147  	res := ed
   148  	newEnv := make([]EnvironmentVariable, 0, len(ed.Env))
   149  	for _, ev := range ed.Env {
   150  		newEnv = append(newEnv, ev.ReplaceString(from, replacement))
   151  	}
   152  	res.Env = newEnv
   153  	return res
   154  }
   155  
   156  // Merge merges two environment definitions according to the join strategy of
   157  // the second one.
   158  //   - Environment variables that are defined in both definitions, are merged with
   159  //     EnvironmentVariable.Merge() and added to the result
   160  //   - Environment variables that are defined in only one of the two definitions,
   161  //     are added to the result directly
   162  func (ed EnvironmentDefinition) Merge(other *EnvironmentDefinition) (*EnvironmentDefinition, error) {
   163  	res := ed
   164  	if other == nil {
   165  		return &res, nil
   166  	}
   167  
   168  	newEnv := []EnvironmentVariable{}
   169  
   170  	thisEnvNames := funk.Map(
   171  		ed.Env,
   172  		func(x EnvironmentVariable) string { return x.Name },
   173  	).([]string)
   174  
   175  	newKeys := make([]string, 0, len(other.Env))
   176  	otherEnvMap := map[string]EnvironmentVariable{}
   177  	for _, ev := range other.Env {
   178  		if !funk.ContainsString(thisEnvNames, ev.Name) {
   179  			newKeys = append(newKeys, ev.Name)
   180  		}
   181  		otherEnvMap[ev.Name] = ev
   182  	}
   183  
   184  	// add new keys to environment
   185  	for _, k := range newKeys {
   186  		oev := otherEnvMap[k]
   187  		newEnv = append(newEnv, oev)
   188  	}
   189  
   190  	// merge keys
   191  	for _, ev := range ed.Env {
   192  		otherEv, ok := otherEnvMap[ev.Name]
   193  		if !ok {
   194  			// if key exists only in this variable, use it
   195  			newEnv = append(newEnv, ev)
   196  		} else {
   197  			// otherwise: merge this variable and the other environment variable
   198  			mev, err := ev.Merge(otherEv)
   199  			if err != nil {
   200  				return &res, err
   201  			}
   202  			newEnv = append(newEnv, *mev)
   203  		}
   204  	}
   205  	res.Env = newEnv
   206  	return &res, nil
   207  }
   208  
   209  // ReplaceString replaces the string 'from' with 'replacement' in
   210  // environment variable values
   211  func (ev EnvironmentVariable) ReplaceString(from string, replacement string) EnvironmentVariable {
   212  	res := ev
   213  	values := make([]string, 0, len(ev.Values))
   214  
   215  	for _, v := range ev.Values {
   216  		values = append(values, strings.ReplaceAll(v, "${INSTALLDIR}", replacement))
   217  	}
   218  	res.Values = values
   219  	return res
   220  }
   221  
   222  // Merge merges two environment variables according to the join strategy defined by
   223  // the second environment variable
   224  // If join strategy of the second variable is "prepend" or "append", the values
   225  // are prepended or appended to the first variable.
   226  // If join strategy is set to "disallowed", the variables need to have exactly
   227  // one value, and both merged values need to be identical, otherwise an error is
   228  // returned.
   229  func (ev EnvironmentVariable) Merge(other EnvironmentVariable) (*EnvironmentVariable, error) {
   230  	res := ev
   231  
   232  	// separators and inherit strategy always need to match for two merged variables
   233  	if ev.Separator != other.Separator || ev.Inherit != other.Inherit {
   234  		return nil, fmt.Errorf("cannot merge environment definitions: incompatible `separator` or `inherit` directives")
   235  	}
   236  
   237  	// 'disallowed' join strategy needs to be set for both or none of the variables
   238  	if (ev.Join == Disallowed || other.Join == Disallowed) && ev.Join != other.Join {
   239  		return nil, fmt.Errorf("cannot merge environment definitions: incompatible `join` directives")
   240  	}
   241  
   242  	switch other.Join {
   243  	case Prepend:
   244  		res.Values = filterValuesUniquely(append(other.Values, ev.Values...), true)
   245  	case Append:
   246  		res.Values = filterValuesUniquely(append(ev.Values, other.Values...), false)
   247  	case Disallowed:
   248  		if len(ev.Values) != 1 || len(other.Values) != 1 || (ev.Values[0] != other.Values[0]) {
   249  			sep := string(ev.Separator)
   250  			return nil, fmt.Errorf(
   251  				"cannot merge environment definitions: no join strategy for variable %s with values %s and %s",
   252  				ev.Name,
   253  				strings.Join(ev.Values, sep), strings.Join(other.Values, sep),
   254  			)
   255  
   256  		}
   257  	default:
   258  		return nil, fmt.Errorf("could not join environment variable %s: invalid `join` directive %v", ev.Name, other.Join)
   259  	}
   260  	res.Join = other.Join
   261  	return &res, nil
   262  }
   263  
   264  // filterValuesUniquely removes duplicate entries from a list of strings
   265  // If `keepFirst` is true, only the first occurrence is kept, otherwise the last
   266  // one.
   267  func filterValuesUniquely(values []string, keepFirst bool) []string {
   268  	nvs := make([]*string, len(values))
   269  	posMap := map[string][]int{}
   270  
   271  	for i, v := range values {
   272  		pmv, ok := posMap[v]
   273  		if !ok {
   274  			pmv = []int{}
   275  		}
   276  		pmv = append(pmv, i)
   277  		posMap[v] = pmv
   278  	}
   279  
   280  	var getPos func([]int) int
   281  	if keepFirst {
   282  		getPos = func(x []int) int { return x[0] }
   283  	} else {
   284  		getPos = func(x []int) int { return x[len(x)-1] }
   285  	}
   286  
   287  	for v, positions := range posMap {
   288  		pos := getPos(positions)
   289  		cv := v
   290  		nvs[pos] = &cv
   291  	}
   292  
   293  	res := make([]string, 0, len(values))
   294  	for _, nv := range nvs {
   295  		if nv != nil {
   296  			res = append(res, *nv)
   297  		}
   298  	}
   299  	return res
   300  }
   301  
   302  // ValueString joins the environment variable values into a single string
   303  // If duplicate values are found, only one of them is considered: for join
   304  // strategy `prepend` only the first occurrence, for join strategy `append` only
   305  // the last one.
   306  func (ev *EnvironmentVariable) ValueString() string {
   307  	return strings.Join(
   308  		filterValuesUniquely(ev.Values, ev.Join == Prepend),
   309  		string(ev.Separator))
   310  }
   311  
   312  // GetEnvBasedOn returns the environment variable names and values defined by
   313  // the EnvironmentDefinition.
   314  // If an environment variable is configured to inherit from the base
   315  // environment (`Inherit==true`), the base environment defined by the
   316  // `envLookup` method is joined with these environment variables.
   317  // This function is mostly used for testing. Use GetEnv() in production.
   318  func (ed *EnvironmentDefinition) GetEnvBasedOn(envLookup func(string) (string, bool)) (map[string]string, error) {
   319  	res := map[string]string{}
   320  
   321  	for _, ev := range ed.Env {
   322  		pev := &ev
   323  		if pev.Inherit {
   324  			osValue, hasOsValue := envLookup(pev.Name)
   325  			if hasOsValue {
   326  				osEv := ev
   327  				osEv.Values = []string{osValue}
   328  				var err error
   329  				pev, err = osEv.Merge(ev)
   330  				if err != nil {
   331  					return nil, err
   332  
   333  				}
   334  			}
   335  		} else if _, hasOsValue := os.LookupEnv(pev.Name); hasOsValue {
   336  			res[pev.Name] = "" // unset
   337  		}
   338  		// only add environment variable if at least one value is set (This allows us to remove variables from the environment.)
   339  		if len(ev.Values) > 0 {
   340  			res[pev.Name] = pev.ValueString()
   341  		}
   342  	}
   343  	return res, nil
   344  }
   345  
   346  // GetEnv returns the environment variable names and values defined by
   347  // the EnvironmentDefinition.
   348  // If an environment variable is configured to inherit from the OS
   349  // environment (`Inherit==true`), the base environment defined by the
   350  // `envLookup` method is joined with these environment variables.
   351  func (ed *EnvironmentDefinition) GetEnv(inherit bool) map[string]string {
   352  	lookupEnv := os.LookupEnv
   353  	if !inherit {
   354  		lookupEnv = func(_ string) (string, bool) { return "", false }
   355  	}
   356  	res, err := ed.GetEnvBasedOn(lookupEnv)
   357  	if err != nil {
   358  		panic(fmt.Sprintf("Could not inherit OS environment variable: %v", err))
   359  	}
   360  	return res
   361  }
   362  
   363  func FilterPATH(env map[string]string, excludes ...string) {
   364  	PATH, exists := env["PATH"]
   365  	if !exists {
   366  		return
   367  	}
   368  
   369  	newPaths := []string{}
   370  	paths := strings.Split(PATH, string(os.PathListSeparator))
   371  	for _, p := range paths {
   372  		pc := filepath.Clean(p)
   373  		includePath := true
   374  		for _, exclude := range excludes {
   375  			if pc == filepath.Clean(exclude) {
   376  				includePath = false
   377  				break
   378  			}
   379  		}
   380  		if includePath {
   381  			newPaths = append(newPaths, p)
   382  		}
   383  	}
   384  
   385  	env["PATH"] = strings.Join(newPaths, string(os.PathListSeparator))
   386  }
   387  
   388  type ExecutablePaths []string
   389  
   390  func (ed *EnvironmentDefinition) ExecutablePaths() (ExecutablePaths, error) {
   391  	env := ed.GetEnv(false)
   392  
   393  	// Retrieve artifact binary directory
   394  	var bins []string
   395  	if p, ok := env["PATH"]; ok {
   396  		bins = strings.Split(p, string(os.PathListSeparator))
   397  	}
   398  
   399  	exes, err := osutils.Executables(bins)
   400  	if err != nil {
   401  		return nil, errs.Wrap(err, "Could not detect executables")
   402  	}
   403  
   404  	// Remove duplicate executables as per PATH and PATHEXT
   405  	exes, err = osutils.UniqueExes(exes, os.Getenv("PATHEXT"))
   406  	if err != nil {
   407  		return nil, errs.Wrap(err, "Could not detect unique executables, make sure your PATH and PATHEXT environment variables are properly configured.")
   408  	}
   409  
   410  	return exes, nil
   411  }
   412  
   413  func (ed *EnvironmentDefinition) ExecutableDirs() (ExecutablePaths, error) {
   414  	exes, err := ed.ExecutablePaths()
   415  	if err != nil {
   416  		return nil, errs.Wrap(err, "Could not get executable paths")
   417  	}
   418  
   419  	var dirs ExecutablePaths
   420  	for _, p := range exes {
   421  		dirs = append(dirs, filepath.Dir(p))
   422  	}
   423  	dirs = funk.UniqString(dirs)
   424  
   425  	return dirs, nil
   426  }
   427  
   428  // FindBinPathFor returns the PATH directory in which the executable can be found.
   429  // If the executable cannot be found, an empty string is returned.
   430  // This function should be called after variables names are expanded with ExpandVariables()
   431  func (ed *EnvironmentDefinition) FindBinPathFor(executable string) string {
   432  	for _, ev := range ed.Env {
   433  		if ev.Name == "PATH" {
   434  			for _, dir := range ev.Values {
   435  				if fileutils.TargetExists(filepath.Join(dir, executable)) {
   436  					return filepath.Clean(filepath.FromSlash(dir))
   437  				}
   438  			}
   439  		}
   440  	}
   441  	return ""
   442  }