github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/modinstaller/mod_installer.go (about)

     1  package modinstaller
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"log"
     7  	"os"
     8  	"path"
     9  	"path/filepath"
    10  
    11  	"github.com/Masterminds/semver/v3"
    12  	git "github.com/go-git/go-git/v5"
    13  	"github.com/otiai10/copy"
    14  	"github.com/spf13/viper"
    15  	"github.com/turbot/steampipe-plugin-sdk/v5/sperr"
    16  	"github.com/turbot/steampipe/pkg/constants"
    17  	"github.com/turbot/steampipe/pkg/error_helpers"
    18  	"github.com/turbot/steampipe/pkg/filepaths"
    19  	"github.com/turbot/steampipe/pkg/plugin"
    20  	"github.com/turbot/steampipe/pkg/steampipeconfig/modconfig"
    21  	"github.com/turbot/steampipe/pkg/steampipeconfig/parse"
    22  	"github.com/turbot/steampipe/pkg/steampipeconfig/versionmap"
    23  	"github.com/turbot/steampipe/pkg/utils"
    24  )
    25  
    26  type ModInstaller struct {
    27  	installData *InstallData
    28  
    29  	// this will be updated as changes are made to dependencies
    30  	workspaceMod *modconfig.Mod
    31  
    32  	// since changes are made to workspaceMod, we need a copy of the Require as is on disk
    33  	// to be able to calculate changes
    34  	oldRequire *modconfig.Require
    35  
    36  	// installed plugins
    37  	installedPlugins map[string]*modconfig.PluginVersionString
    38  
    39  	mods versionmap.VersionConstraintMap
    40  
    41  	// the final resting place of all dependency mods
    42  	modsPath string
    43  	// a shadow directory for installing mods
    44  	// this is necessary to make mod installation transactional
    45  	shadowDirPath string
    46  
    47  	workspacePath string
    48  
    49  	// what command is being run
    50  	command string
    51  	// are dependencies being added to the workspace
    52  	dryRun bool
    53  	// do we force install even if there are require errors
    54  	force bool
    55  }
    56  
    57  func NewModInstaller(ctx context.Context, opts *InstallOpts) (*ModInstaller, error) {
    58  	if opts.WorkspaceMod == nil {
    59  		return nil, sperr.New("no workspace mod passed to mod installer")
    60  	}
    61  	i := &ModInstaller{
    62  		workspacePath: opts.WorkspaceMod.ModPath,
    63  		workspaceMod:  opts.WorkspaceMod,
    64  		command:       opts.Command,
    65  		dryRun:        opts.DryRun,
    66  		force:         opts.Force,
    67  	}
    68  
    69  	if opts.WorkspaceMod.Require != nil {
    70  		i.oldRequire = opts.WorkspaceMod.Require.Clone()
    71  	}
    72  
    73  	if err := i.setModsPath(); err != nil {
    74  		return nil, err
    75  	}
    76  
    77  	installedPlugins, err := plugin.GetInstalledPlugins(ctx)
    78  	if err != nil {
    79  		return nil, err
    80  	}
    81  	i.installedPlugins = installedPlugins
    82  
    83  	// load lock file
    84  	workspaceLock, err := versionmap.LoadWorkspaceLock(ctx, i.workspacePath)
    85  	if err != nil {
    86  		return nil, err
    87  	}
    88  
    89  	// create install data
    90  	i.installData = NewInstallData(workspaceLock, i.workspaceMod)
    91  
    92  	// parse args to get the required mod versions
    93  	requiredMods, err := i.GetRequiredModVersionsFromArgs(opts.ModArgs)
    94  	if err != nil {
    95  		return nil, err
    96  	}
    97  	i.mods = requiredMods
    98  
    99  	return i, nil
   100  }
   101  
   102  func (i *ModInstaller) removeOldShadowDirectories() error {
   103  	removeErrors := []error{}
   104  	// get the parent of the 'mods' directory - all shadow directories are siblings of this
   105  	parent := filepath.Base(i.modsPath)
   106  	entries, err := os.ReadDir(parent)
   107  	if err != nil {
   108  		return err
   109  	}
   110  	for _, dir := range entries {
   111  		if dir.IsDir() && filepaths.IsModInstallShadowPath(dir.Name()) {
   112  			err := os.RemoveAll(filepath.Join(parent, dir.Name()))
   113  			if err != nil {
   114  				removeErrors = append(removeErrors, err)
   115  			}
   116  		}
   117  	}
   118  	return error_helpers.CombineErrors(removeErrors...)
   119  }
   120  
   121  func (i *ModInstaller) setModsPath() error {
   122  	i.modsPath = filepaths.WorkspaceModPath(i.workspacePath)
   123  	_ = i.removeOldShadowDirectories()
   124  	i.shadowDirPath = filepaths.WorkspaceModShadowPath(i.workspacePath)
   125  	return nil
   126  }
   127  
   128  func (i *ModInstaller) UninstallWorkspaceDependencies(ctx context.Context) error {
   129  	workspaceMod := i.workspaceMod
   130  
   131  	// remove required dependencies from the mod file
   132  	if len(i.mods) == 0 {
   133  		workspaceMod.RemoveAllModDependencies()
   134  
   135  	} else {
   136  		// verify all the mods specifed in the args exist in the modfile
   137  		workspaceMod.RemoveModDependencies(i.mods)
   138  	}
   139  
   140  	// uninstall by calling Install
   141  	if err := i.installMods(ctx, workspaceMod.Require.Mods, workspaceMod); err != nil {
   142  		return err
   143  	}
   144  
   145  	if workspaceMod.Require.Empty() {
   146  		workspaceMod.Require = nil
   147  	}
   148  
   149  	// if this is a dry run, return now
   150  	if i.dryRun {
   151  		log.Printf("[TRACE] UninstallWorkspaceDependencies - dry-run=true, returning before saving mod file and cache\n")
   152  		return nil
   153  	}
   154  
   155  	// write the lock file
   156  	if err := i.installData.Lock.Save(); err != nil {
   157  		return err
   158  	}
   159  
   160  	//  now safe to save the mod file
   161  	if err := i.updateModFile(); err != nil {
   162  		return err
   163  	}
   164  
   165  	// tidy unused mods
   166  	if viper.GetBool(constants.ArgPrune) {
   167  		if _, err := i.Prune(); err != nil {
   168  			return err
   169  		}
   170  	}
   171  	return nil
   172  }
   173  
   174  // InstallWorkspaceDependencies installs all dependencies of the workspace mod
   175  func (i *ModInstaller) InstallWorkspaceDependencies(ctx context.Context) (err error) {
   176  	workspaceMod := i.workspaceMod
   177  	defer func() {
   178  		if err != nil && i.force {
   179  			// suppress the error since this is a forced install
   180  			log.Println("[TRACE] suppressing error in InstallWorkspaceDependencies because force is enabled", err)
   181  			err = nil
   182  		}
   183  		// tidy unused mods
   184  		// (put in defer so it still gets called in case of errors)
   185  		if viper.GetBool(constants.ArgPrune) && !i.dryRun {
   186  			// be sure not to overwrite an existing return error
   187  			_, pruneErr := i.Prune()
   188  			if pruneErr != nil && err == nil {
   189  				err = pruneErr
   190  			}
   191  		}
   192  	}()
   193  
   194  	if validationErrors := workspaceMod.ValidateRequirements(i.installedPlugins); len(validationErrors) > 0 {
   195  		if !i.force {
   196  			// if this is not a force install, return errors in validation
   197  			return error_helpers.CombineErrors(validationErrors...)
   198  		}
   199  		// ignore if this is a force install
   200  		// TODO: raise warnings for errors getting suppressed [https://github.com/turbot/steampipe/issues/3364]
   201  		log.Println("[TRACE] suppressing mod validation error", validationErrors)
   202  	}
   203  
   204  	// if mod args have been provided, add them to the workspace mod requires
   205  	// (this will replace any existing dependencies of same name)
   206  	if len(i.mods) > 0 {
   207  		workspaceMod.AddModDependencies(i.mods)
   208  	}
   209  
   210  	if err := i.installMods(ctx, workspaceMod.Require.Mods, workspaceMod); err != nil {
   211  		return err
   212  	}
   213  
   214  	// if this is a dry run, return now
   215  	if i.dryRun {
   216  		log.Printf("[TRACE] InstallWorkspaceDependencies - dry-run=true, returning before saving mod file and cache\n")
   217  		return nil
   218  	}
   219  
   220  	// write the lock file
   221  	if err := i.installData.Lock.Save(); err != nil {
   222  		return err
   223  	}
   224  
   225  	//  now safe to save the mod file
   226  	if err := i.updateModFile(); err != nil {
   227  		return err
   228  	}
   229  
   230  	if !workspaceMod.HasDependentMods() {
   231  		// there are no dependencies - delete the cache
   232  		i.installData.Lock.Delete()
   233  	}
   234  	return nil
   235  }
   236  
   237  func (i *ModInstaller) GetModList() string {
   238  	return i.installData.Lock.GetModList(i.workspaceMod.GetInstallCacheKey())
   239  }
   240  
   241  // commitShadow recursively copies over the contents of the shadow directory
   242  // to the mods directory, replacing conflicts as it goes
   243  // (uses `os.Create(dest)` under the hood - which truncates the target)
   244  func (i *ModInstaller) commitShadow(ctx context.Context) error {
   245  	if error_helpers.IsContextCanceled(ctx) {
   246  		return ctx.Err()
   247  	}
   248  	if _, err := os.Stat(i.shadowDirPath); os.IsNotExist(err) {
   249  		// nothing to do here
   250  		// there's no shadow directory to commit
   251  		// this is not an error and may happen when install does not make any changes
   252  		return nil
   253  	}
   254  	entries, err := os.ReadDir(i.shadowDirPath)
   255  	if err != nil {
   256  		return sperr.WrapWithRootMessage(err, "could not read shadow directory")
   257  	}
   258  	for _, entry := range entries {
   259  		if !entry.IsDir() {
   260  			continue
   261  		}
   262  		source := filepath.Join(i.shadowDirPath, entry.Name())
   263  		destination := filepath.Join(i.modsPath, entry.Name())
   264  		log.Println("[TRACE] copying", source, destination)
   265  		if err := copy.Copy(source, destination); err != nil {
   266  			return sperr.WrapWithRootMessage(err, "could not commit shadow directory '%s'", entry.Name())
   267  		}
   268  	}
   269  	return nil
   270  }
   271  
   272  func (i *ModInstaller) shouldCommitShadow(ctx context.Context, installError error) bool {
   273  	// no commit if this is a dry run
   274  	if i.dryRun {
   275  		return false
   276  	}
   277  	// commit if this is forced - even if there's errors
   278  	return installError == nil || i.force
   279  }
   280  
   281  func (i *ModInstaller) installMods(ctx context.Context, mods []*modconfig.ModVersionConstraint, parent *modconfig.Mod) (err error) {
   282  	defer func() {
   283  		var commitErr error
   284  		if i.shouldCommitShadow(ctx, err) {
   285  			commitErr = i.commitShadow(ctx)
   286  		}
   287  
   288  		// if this was forced, we need to suppress the install error
   289  		// otherwise the calling code will fail
   290  		if i.force {
   291  			err = nil
   292  		}
   293  
   294  		// ensure we return any commit error
   295  		if commitErr != nil {
   296  			err = commitErr
   297  		}
   298  
   299  		// force remove the shadow directory - we can ignore any error here, since
   300  		// these directories get cleaned up before any install session
   301  		os.RemoveAll(i.shadowDirPath)
   302  	}()
   303  
   304  	var errors []error
   305  	for _, requiredModVersion := range mods {
   306  		modToUse, err := i.getCurrentlyInstalledVersionToUse(ctx, requiredModVersion, parent, i.updating())
   307  		if err != nil {
   308  			errors = append(errors, err)
   309  			continue
   310  		}
   311  
   312  		// if the mod is not installed or needs updating, OR if this is an update command,
   313  		// pass shouldUpdate=true into installModDependencesRecursively
   314  		// this ensures that we update any dependencies which have updates available
   315  		shouldUpdate := modToUse == nil || i.updating()
   316  		if err := i.installModDependencesRecursively(ctx, requiredModVersion, modToUse, parent, shouldUpdate); err != nil {
   317  			errors = append(errors, err)
   318  		}
   319  	}
   320  
   321  	// update the lock to be the new lock, and record any uninstalled mods
   322  	i.installData.onInstallComplete()
   323  
   324  	return i.buildInstallError(errors)
   325  }
   326  
   327  func (i *ModInstaller) buildInstallError(errors []error) error {
   328  	if len(errors) == 0 {
   329  		return nil
   330  	}
   331  	verb := "install"
   332  	if i.updating() {
   333  		verb = "update"
   334  	}
   335  	prefix := fmt.Sprintf("%d %s failed to %s", len(errors), utils.Pluralize("dependency", len(errors)), verb)
   336  	err := error_helpers.CombineErrorsWithPrefix(prefix, errors...)
   337  	return err
   338  }
   339  
   340  func (i *ModInstaller) installModDependencesRecursively(ctx context.Context, requiredModVersion *modconfig.ModVersionConstraint, dependencyMod *modconfig.Mod, parent *modconfig.Mod, shouldUpdate bool) error {
   341  	if error_helpers.IsContextCanceled(ctx) {
   342  		// short circuit if the execution context has been cancelled
   343  		return ctx.Err()
   344  	}
   345  	// get available versions for this mod
   346  	includePrerelease := requiredModVersion.Constraint.IsPrerelease()
   347  	availableVersions, err := i.installData.getAvailableModVersions(requiredModVersion.Name, includePrerelease)
   348  
   349  	if err != nil {
   350  		return err
   351  	}
   352  
   353  	var errors []error
   354  
   355  	if dependencyMod == nil {
   356  		// get a resolved mod ref that satisfies the version constraints
   357  		resolvedRef, err := i.getModRefSatisfyingConstraints(requiredModVersion, availableVersions)
   358  		if err != nil {
   359  			return err
   360  		}
   361  
   362  		// install the mod
   363  		dependencyMod, err = i.install(ctx, resolvedRef, parent)
   364  		if err != nil {
   365  			return err
   366  		}
   367  
   368  		validationErrors := dependencyMod.ValidateRequirements(i.installedPlugins)
   369  		errors = append(errors, validationErrors...)
   370  	} else {
   371  		// update the install data
   372  		i.installData.addExisting(requiredModVersion.Name, dependencyMod, requiredModVersion.Constraint, parent)
   373  		log.Printf("[TRACE] not installing %s with version constraint %s as version %s is already installed", requiredModVersion.Name, requiredModVersion.Constraint.Original, dependencyMod.Version)
   374  	}
   375  
   376  	// to get here we have the dependency mod - either we installed it or it was already installed
   377  	// recursively install its dependencies
   378  	for _, childDependency := range dependencyMod.Require.Mods {
   379  		childDependencyMod, err := i.getCurrentlyInstalledVersionToUse(ctx, childDependency, dependencyMod, shouldUpdate)
   380  		if err != nil {
   381  			errors = append(errors, err)
   382  			continue
   383  		}
   384  		if err := i.installModDependencesRecursively(ctx, childDependency, childDependencyMod, dependencyMod, shouldUpdate); err != nil {
   385  			errors = append(errors, err)
   386  			continue
   387  		}
   388  	}
   389  
   390  	return error_helpers.CombineErrorsWithPrefix(fmt.Sprintf("%d child %s failed to install", len(errors), utils.Pluralize("dependency", len(errors))), errors...)
   391  }
   392  
   393  func (i *ModInstaller) getCurrentlyInstalledVersionToUse(ctx context.Context, requiredModVersion *modconfig.ModVersionConstraint, parent *modconfig.Mod, forceUpdate bool) (*modconfig.Mod, error) {
   394  	// do we have an installed version of this mod matching the required mod constraint
   395  	installedVersion, err := i.installData.Lock.GetLockedModVersion(requiredModVersion, parent)
   396  	if err != nil {
   397  		return nil, err
   398  	}
   399  	if installedVersion == nil {
   400  		return nil, nil
   401  	}
   402  
   403  	// can we update this
   404  	canUpdate, err := i.canUpdateMod(installedVersion, requiredModVersion, forceUpdate)
   405  	if err != nil {
   406  		return nil, err
   407  
   408  	}
   409  	if canUpdate {
   410  		// return nil mod to indicate we should update
   411  		return nil, nil
   412  	}
   413  
   414  	// load the existing mod and return
   415  	return i.loadDependencyMod(ctx, installedVersion)
   416  }
   417  
   418  // loadDependencyMod tries to load the mod definition from the shadow directory
   419  // and falls back to the 'mods' directory of the root mod
   420  func (i *ModInstaller) loadDependencyMod(ctx context.Context, modVersion *versionmap.ResolvedVersionConstraint) (*modconfig.Mod, error) {
   421  	// construct the dependency path - this is the relative path of the dependency we are installing
   422  	dependencyPath := modVersion.DependencyPath()
   423  
   424  	// first try loading from the shadow dir
   425  	modDefinition, err := i.loadDependencyModFromRoot(ctx, i.shadowDirPath, dependencyPath)
   426  	if err != nil {
   427  		return nil, err
   428  	}
   429  
   430  	// failed to load from shadow dir, try mods dir
   431  	if modDefinition == nil {
   432  		modDefinition, err = i.loadDependencyModFromRoot(ctx, i.modsPath, dependencyPath)
   433  		if err != nil {
   434  			return nil, err
   435  		}
   436  	}
   437  
   438  	// if we still failed, give up
   439  	if modDefinition == nil {
   440  		return nil, fmt.Errorf("could not find dependency mod '%s'", dependencyPath)
   441  	}
   442  
   443  	// set the DependencyName, DependencyPath and Version properties on the mod
   444  	if err := i.setModDependencyConfig(modDefinition, dependencyPath); err != nil {
   445  		return nil, err
   446  	}
   447  
   448  	return modDefinition, nil
   449  }
   450  
   451  func (i *ModInstaller) loadDependencyModFromRoot(ctx context.Context, modInstallRoot string, dependencyPath string) (*modconfig.Mod, error) {
   452  	log.Printf("[TRACE] loadDependencyModFromRoot: trying to load %s from root %s", dependencyPath, modInstallRoot)
   453  
   454  	modPath := path.Join(modInstallRoot, dependencyPath)
   455  	modDefinition, err := parse.LoadModfile(modPath)
   456  	if err != nil {
   457  		return nil, sperr.WrapWithMessage(err, "failed to load mod definition for %s from %s", dependencyPath, modInstallRoot)
   458  	}
   459  	return modDefinition, nil
   460  }
   461  
   462  // determine if we should update this mod, and if so whether there is an update available
   463  func (i *ModInstaller) canUpdateMod(installedVersion *versionmap.ResolvedVersionConstraint, requiredModVersion *modconfig.ModVersionConstraint, forceUpdate bool) (bool, error) {
   464  	// so should we update?
   465  	// if forceUpdate is set or if the required version constraint is different to the locked version constraint, update
   466  	isSatisfied, errs := requiredModVersion.Constraint.Validate(installedVersion.Version)
   467  	if len(errs) > 0 {
   468  		return false, error_helpers.CombineErrors(errs...)
   469  	}
   470  	if forceUpdate || !isSatisfied {
   471  		// get available versions for this mod
   472  		includePrerelease := requiredModVersion.Constraint.IsPrerelease()
   473  		availableVersions, err := i.installData.getAvailableModVersions(requiredModVersion.Name, includePrerelease)
   474  		if err != nil {
   475  			return false, err
   476  		}
   477  
   478  		return i.updateAvailable(requiredModVersion, installedVersion.Version, availableVersions)
   479  	}
   480  	return false, nil
   481  
   482  }
   483  
   484  // determine whether there is a newer mod version avoilable which satisfies the dependency version constraint
   485  func (i *ModInstaller) updateAvailable(requiredVersion *modconfig.ModVersionConstraint, currentVersion *semver.Version, availableVersions []*semver.Version) (bool, error) {
   486  	latestVersion, err := i.getModRefSatisfyingConstraints(requiredVersion, availableVersions)
   487  	if err != nil {
   488  		return false, err
   489  	}
   490  	if latestVersion.Version.GreaterThan(currentVersion) {
   491  		return true, nil
   492  	}
   493  	return false, nil
   494  }
   495  
   496  // get the most recent available mod version which satisfies the version constraint
   497  func (i *ModInstaller) getModRefSatisfyingConstraints(modVersion *modconfig.ModVersionConstraint, availableVersions []*semver.Version) (*ResolvedModRef, error) {
   498  	// find a version which satisfies the version constraint
   499  	var version = getVersionSatisfyingConstraint(modVersion.Constraint, availableVersions)
   500  	if version == nil {
   501  		return nil, fmt.Errorf("no version of %s found satisfying version constraint: %s", modVersion.Name, modVersion.Constraint.Original)
   502  	}
   503  
   504  	return NewResolvedModRef(modVersion, version)
   505  }
   506  
   507  // install a mod
   508  func (i *ModInstaller) install(ctx context.Context, dependency *ResolvedModRef, parent *modconfig.Mod) (_ *modconfig.Mod, err error) {
   509  	var modDef *modconfig.Mod
   510  	// get the temp location to install the mod to
   511  	dependencyPath := dependency.DependencyPath()
   512  	destPath := i.getDependencyShadowPath(dependencyPath)
   513  
   514  	defer func() {
   515  		if err == nil {
   516  			i.installData.onModInstalled(dependency, modDef, parent)
   517  		}
   518  	}()
   519  	// if the target path exists, use the exiting file
   520  	// if it does not exist (the usual case), install it
   521  	if _, err := os.Stat(destPath); os.IsNotExist(err) {
   522  		log.Println("[TRACE] installing", dependencyPath, "in", destPath)
   523  		if err := i.installFromGit(dependency, destPath); err != nil {
   524  			return nil, err
   525  		}
   526  	}
   527  
   528  	// now load the installed mod and return it
   529  	modDef, err = parse.LoadModfile(destPath)
   530  	if err != nil {
   531  		return nil, err
   532  	}
   533  	if modDef == nil {
   534  		return nil, fmt.Errorf("'%s' has no mod definition file", dependencyPath)
   535  	}
   536  
   537  	if !i.dryRun {
   538  		// now the mod is installed in its final location, set mod dependency path
   539  		if err := i.setModDependencyConfig(modDef, dependencyPath); err != nil {
   540  			return nil, err
   541  		}
   542  	}
   543  
   544  	return modDef, nil
   545  }
   546  
   547  func (i *ModInstaller) installFromGit(dependency *ResolvedModRef, installPath string) error {
   548  	// get the mod from git
   549  	gitUrl := getGitUrl(dependency.Name)
   550  	log.Println("[TRACE] >>> cloning", gitUrl, dependency.GitReference)
   551  	_, err := git.PlainClone(installPath,
   552  		false,
   553  		&git.CloneOptions{
   554  			URL:           gitUrl,
   555  			ReferenceName: dependency.GitReference,
   556  			Depth:         1,
   557  			SingleBranch:  true,
   558  		})
   559  	if err != nil {
   560  		return sperr.WrapWithMessage(err, "failed to clone mod '%s' from git", dependency.Name)
   561  	}
   562  	// verify the cloned repo contains a valid modfile
   563  	return i.verifyModFile(dependency, installPath)
   564  }
   565  
   566  // build the path of the temp location to copy this depednency to
   567  func (i *ModInstaller) getDependencyDestPath(dependencyFullName string) string {
   568  	return filepath.Join(i.modsPath, dependencyFullName)
   569  }
   570  
   571  // build the path of the temp location to copy this depednency to
   572  func (i *ModInstaller) getDependencyShadowPath(dependencyFullName string) string {
   573  	return filepath.Join(i.shadowDirPath, dependencyFullName)
   574  }
   575  
   576  // set the mod dependency path
   577  func (i *ModInstaller) setModDependencyConfig(mod *modconfig.Mod, dependencyPath string) error {
   578  	return mod.SetDependencyConfig(dependencyPath)
   579  }
   580  
   581  func (i *ModInstaller) updating() bool {
   582  	return i.command == "update"
   583  }
   584  
   585  func (i *ModInstaller) uninstalling() bool {
   586  	return i.command == "uninstall"
   587  }
   588  
   589  func (i *ModInstaller) verifyModFile(dependency *ResolvedModRef, installPath string) error {
   590  	for _, modFilePath := range filepaths.ModFilePaths(installPath) {
   591  		_, err := os.Stat(modFilePath)
   592  		if err == nil {
   593  			// found the modfile
   594  			return nil
   595  		}
   596  	}
   597  	return sperr.New("mod '%s' does not contain a valid mod file", dependency.Name)
   598  }