github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/steampipeconfig/versionmap/workspace_lock.go (about)

     1  package versionmap
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"log"
     8  	"os"
     9  	"path"
    10  	"path/filepath"
    11  	"strings"
    12  
    13  	"github.com/Masterminds/semver/v3"
    14  	filehelpers "github.com/turbot/go-kit/files"
    15  	"github.com/turbot/steampipe/pkg/error_helpers"
    16  	"github.com/turbot/steampipe/pkg/filepaths"
    17  	"github.com/turbot/steampipe/pkg/steampipeconfig/modconfig"
    18  	"github.com/turbot/steampipe/pkg/versionhelpers"
    19  )
    20  
    21  const WorkspaceLockStructVersion = 20220411
    22  
    23  // WorkspaceLock is a map of ModVersionMaps items keyed by the parent mod whose dependencies are installed
    24  type WorkspaceLock struct {
    25  	WorkspacePath   string
    26  	InstallCache    DependencyVersionMap
    27  	MissingVersions DependencyVersionMap
    28  
    29  	ModInstallationPath string
    30  	installedMods       VersionListMap
    31  }
    32  
    33  // EmptyWorkspaceLock creates a new empty workspace lock based,
    34  // sharing workspace path and installedMods with 'existingLock'
    35  func EmptyWorkspaceLock(existingLock *WorkspaceLock) *WorkspaceLock {
    36  	return &WorkspaceLock{
    37  		WorkspacePath:       existingLock.WorkspacePath,
    38  		ModInstallationPath: filepaths.WorkspaceModPath(existingLock.WorkspacePath),
    39  		InstallCache:        make(DependencyVersionMap),
    40  		MissingVersions:     make(DependencyVersionMap),
    41  		installedMods:       existingLock.installedMods,
    42  	}
    43  }
    44  
    45  func LoadWorkspaceLock(ctx context.Context, workspacePath string) (*WorkspaceLock, error) {
    46  	var installCache = make(DependencyVersionMap)
    47  	lockPath := filepaths.WorkspaceLockPath(workspacePath)
    48  	if filehelpers.FileExists(lockPath) {
    49  		fileContent, err := os.ReadFile(lockPath)
    50  		if err != nil {
    51  			log.Printf("[TRACE] error reading %s: %s\n", lockPath, err.Error())
    52  			return nil, err
    53  		}
    54  		err = json.Unmarshal(fileContent, &installCache)
    55  		if err != nil {
    56  			log.Printf("[TRACE] failed to unmarshal %s: %s\n", lockPath, err.Error())
    57  			return nil, err
    58  		}
    59  	}
    60  	res := &WorkspaceLock{
    61  		WorkspacePath:       workspacePath,
    62  		ModInstallationPath: filepaths.WorkspaceModPath(workspacePath),
    63  		InstallCache:        installCache,
    64  		MissingVersions:     make(DependencyVersionMap),
    65  	}
    66  
    67  	if err := res.getInstalledMods(ctx); err != nil {
    68  		return nil, err
    69  	}
    70  
    71  	// populate the MissingVersions
    72  	// (this removes missing items from the install cache)
    73  	res.setMissing()
    74  
    75  	return res, nil
    76  }
    77  
    78  // getInstalledMods returns a map installed mods, and the versions installed for each
    79  func (l *WorkspaceLock) getInstalledMods(ctx context.Context) error {
    80  	// recursively search for all the mod.sp files under the .steampipe/mods folder, then build the mod name from the file path
    81  	modFiles, err := filehelpers.ListFilesWithContext(ctx, l.ModInstallationPath, &filehelpers.ListOptions{
    82  		Flags:   filehelpers.FilesRecursive,
    83  		Include: []string{"**/mod.sp"},
    84  	})
    85  	if err != nil {
    86  		return err
    87  	}
    88  
    89  	// create result map - a list of version for each mod
    90  	installedMods := make(VersionListMap, len(modFiles))
    91  	// collect errors
    92  	var errors []error
    93  
    94  	for _, modfilePath := range modFiles {
    95  		if ctx.Err() != nil {
    96  			return ctx.Err()
    97  		}
    98  		// try to parse the mon name and version form the parent folder of the modfile
    99  		modDependencyName, version, err := l.parseModPath(modfilePath)
   100  		if err != nil {
   101  			// if we fail to parse, just ignore this modfile
   102  			// - it's parent is not a valid mod installation folder so it is probably a child folder of a mod
   103  			continue
   104  		}
   105  
   106  		// ensure the dependency mod folder is correctly named
   107  		// - for old versions of steampipe the folder name would omit the patch number
   108  		if err := l.validateAndFixFolderNamingFormat(modDependencyName, version, modfilePath); err != nil {
   109  			continue
   110  		}
   111  
   112  		// add this mod version to the map
   113  		installedMods.Add(modDependencyName, version)
   114  	}
   115  
   116  	if len(errors) > 0 {
   117  		return error_helpers.CombineErrors(errors...)
   118  	}
   119  	l.installedMods = installedMods
   120  	return nil
   121  }
   122  
   123  func (l *WorkspaceLock) validateAndFixFolderNamingFormat(modName string, version *semver.Version, modfilePath string) error {
   124  	// verify folder name is of correct format (i.e. including patch number)
   125  	modDir := filepath.Dir(modfilePath)
   126  	parts := strings.Split(modDir, "@")
   127  	currentVersionString := parts[1]
   128  	desiredVersionString := fmt.Sprintf("v%s", version.String())
   129  	if desiredVersionString != currentVersionString {
   130  		desiredDir := fmt.Sprintf("%s@%s", parts[0], desiredVersionString)
   131  		log.Printf("[TRACE] renaming dependency mod folder %s to %s", modDir, desiredDir)
   132  		return os.Rename(modDir, desiredDir)
   133  	}
   134  	return nil
   135  }
   136  
   137  // GetUnreferencedMods returns a map of all installed mods which are not in the lock file
   138  func (l *WorkspaceLock) GetUnreferencedMods() VersionListMap {
   139  	var unreferencedVersions = make(VersionListMap)
   140  	for name, versions := range l.installedMods {
   141  		for _, version := range versions {
   142  			if !l.ContainsModVersion(name, version) {
   143  				unreferencedVersions.Add(name, version)
   144  			}
   145  		}
   146  	}
   147  	return unreferencedVersions
   148  }
   149  
   150  // identify mods which are in InstallCache but not installed
   151  // move them from InstallCache into MissingVersions
   152  func (l *WorkspaceLock) setMissing() {
   153  	// create a map of full modname to bool to allow simple checking
   154  	flatInstalled := l.installedMods.FlatMap()
   155  
   156  	for parent, deps := range l.InstallCache {
   157  		// deps is a map of dep name to resolved contraint list
   158  		// flatten and iterate
   159  
   160  		for name, resolvedConstraint := range deps {
   161  			fullName := modconfig.BuildModDependencyPath(name, resolvedConstraint.Version)
   162  
   163  			if !flatInstalled[fullName] {
   164  				// get the mod name from the constraint (fullName includes the version)
   165  				name := resolvedConstraint.Name
   166  				// remove this item from the install cache and add into missing
   167  				l.MissingVersions.Add(name, resolvedConstraint.Alias, resolvedConstraint.Version, resolvedConstraint.Constraint, parent)
   168  				l.InstallCache[parent].Remove(name)
   169  			}
   170  		}
   171  	}
   172  }
   173  
   174  // extract the mod name and version from the modfile path
   175  func (l *WorkspaceLock) parseModPath(modfilePath string) (modDependencyName string, modVersion *semver.Version, err error) {
   176  	modFullName, err := filepath.Rel(l.ModInstallationPath, filepath.Dir(modfilePath))
   177  	if err != nil {
   178  		return
   179  	}
   180  	return modconfig.ParseModDependencyPath(modFullName)
   181  }
   182  
   183  func (l *WorkspaceLock) Save() error {
   184  	if len(l.InstallCache) == 0 {
   185  		// ignore error
   186  		l.Delete()
   187  		return nil
   188  	}
   189  	content, err := json.MarshalIndent(l.InstallCache, "", "  ")
   190  	if err != nil {
   191  		return err
   192  	}
   193  	return os.WriteFile(filepaths.WorkspaceLockPath(l.WorkspacePath), content, 0644)
   194  }
   195  
   196  // Delete deletes the lock file
   197  func (l *WorkspaceLock) Delete() error {
   198  	if filehelpers.FileExists(filepaths.WorkspaceLockPath(l.WorkspacePath)) {
   199  		return os.Remove(filepaths.WorkspaceLockPath(l.WorkspacePath))
   200  	}
   201  	return nil
   202  }
   203  
   204  // DeleteMods removes mods from the lock file then, if it is empty, deletes the file
   205  func (l *WorkspaceLock) DeleteMods(mods VersionConstraintMap, parent *modconfig.Mod) {
   206  	for modName := range mods {
   207  		if parentDependencies := l.InstallCache[parent.GetInstallCacheKey()]; parentDependencies != nil {
   208  			parentDependencies.Remove(modName)
   209  		}
   210  	}
   211  }
   212  
   213  // GetMod looks for a lock file entry matching the given mod dependency name
   214  // (e.g.github.com/turbot/steampipe-mod-azure-thrifty
   215  func (l *WorkspaceLock) GetMod(modDependencyName string, parent *modconfig.Mod) *ResolvedVersionConstraint {
   216  	parentKey := parent.GetInstallCacheKey()
   217  
   218  	if parentDependencies := l.InstallCache[parentKey]; parentDependencies != nil {
   219  		// look for this mod in the lock file entries for this parent
   220  		return parentDependencies[modDependencyName]
   221  	}
   222  	return nil
   223  }
   224  
   225  // GetLockedModVersions builds a ResolvedVersionListMap with the resolved versions
   226  // for each item of the given VersionConstraintMap found in the lock file
   227  func (l *WorkspaceLock) GetLockedModVersions(mods VersionConstraintMap, parent *modconfig.Mod) (ResolvedVersionListMap, error) {
   228  	var res = make(ResolvedVersionListMap)
   229  	for name, constraint := range mods {
   230  		resolvedConstraint, err := l.GetLockedModVersion(constraint, parent)
   231  		if err != nil {
   232  			return nil, err
   233  		}
   234  		if resolvedConstraint != nil {
   235  			res.Add(name, resolvedConstraint)
   236  		}
   237  	}
   238  	return res, nil
   239  }
   240  
   241  // GetLockedModVersion looks for a lock file entry matching the required constraint and returns nil if not found
   242  func (l *WorkspaceLock) GetLockedModVersion(requiredModVersion *modconfig.ModVersionConstraint, parent *modconfig.Mod) (*ResolvedVersionConstraint, error) {
   243  	lockedVersion := l.GetMod(requiredModVersion.Name, parent)
   244  	if lockedVersion == nil {
   245  		return nil, nil
   246  	}
   247  
   248  	// verify the locked version satisfies the version constraint
   249  	if !requiredModVersion.Constraint.Check(lockedVersion.Version) {
   250  		return nil, nil
   251  	}
   252  
   253  	return lockedVersion, nil
   254  }
   255  
   256  // EnsureLockedModVersion looks for a lock file entry matching the required mod name
   257  func (l *WorkspaceLock) EnsureLockedModVersion(requiredModVersion *modconfig.ModVersionConstraint, parent *modconfig.Mod) (*ResolvedVersionConstraint, error) {
   258  	lockedVersion := l.GetMod(requiredModVersion.Name, parent)
   259  	if lockedVersion == nil {
   260  		return nil, nil
   261  	}
   262  
   263  	// verify the locked version satisfies the version constraint
   264  	if !requiredModVersion.Constraint.Check(lockedVersion.Version) {
   265  		return nil, fmt.Errorf("failed to resolve dependencies for %s - locked version %s does not meet the constraint %s", parent.GetInstallCacheKey(), modconfig.BuildModDependencyPath(requiredModVersion.Name, lockedVersion.Version), requiredModVersion.Constraint.Original)
   266  	}
   267  
   268  	return lockedVersion, nil
   269  }
   270  
   271  // GetLockedModVersionConstraint looks for a lock file entry matching the required mod version and if found,
   272  // returns it in the form of a ModVersionConstraint
   273  func (l *WorkspaceLock) GetLockedModVersionConstraint(requiredModVersion *modconfig.ModVersionConstraint, parent *modconfig.Mod) (*modconfig.ModVersionConstraint, error) {
   274  	lockedVersion, err := l.EnsureLockedModVersion(requiredModVersion, parent)
   275  	if err != nil {
   276  		// EnsureLockedModVersion returns an error if the locked version does not satisfy the requirement
   277  		return nil, err
   278  	}
   279  	if lockedVersion == nil {
   280  		// EnsureLockedModVersion returns nil if no locked version is found
   281  		return nil, nil
   282  	}
   283  	// create a new ModVersionConstraint using the locked version
   284  	lockedVersionFullName := modconfig.BuildModDependencyPath(requiredModVersion.Name, lockedVersion.Version)
   285  	return modconfig.NewModVersionConstraint(lockedVersionFullName)
   286  }
   287  
   288  // ContainsModVersion returns whether the lockfile contains the given mod version
   289  func (l *WorkspaceLock) ContainsModVersion(modName string, modVersion *semver.Version) bool {
   290  	for _, modVersionMap := range l.InstallCache {
   291  		for lockName, lockVersion := range modVersionMap {
   292  			// TODO consider handling of metadata
   293  			if lockName == modName && lockVersion.Version.Equal(modVersion) && lockVersion.Version.Metadata() == modVersion.Metadata() {
   294  				return true
   295  			}
   296  		}
   297  	}
   298  	return false
   299  }
   300  
   301  func (l *WorkspaceLock) ContainsModConstraint(modName string, constraint *versionhelpers.Constraints) bool {
   302  	for _, modVersionMap := range l.InstallCache {
   303  		for lockName, lockVersion := range modVersionMap {
   304  			if lockName == modName && lockVersion.Constraint == constraint.Original {
   305  				return true
   306  			}
   307  		}
   308  	}
   309  	return false
   310  }
   311  
   312  // Incomplete returned whether there are any missing dependencies
   313  // (i.e. they exist in the lock file but ate not installed)
   314  func (l *WorkspaceLock) Incomplete() bool {
   315  	return len(l.MissingVersions) > 0
   316  }
   317  
   318  // Empty returns whether the install cache is empty
   319  func (l *WorkspaceLock) Empty() bool {
   320  	return l == nil || len(l.InstallCache) == 0
   321  }
   322  
   323  // StructVersion returns the struct version of the workspace lock
   324  // because only the InstallCache is serialised, read the StructVersion from the first install cache entry
   325  func (l *WorkspaceLock) StructVersion() int {
   326  	for _, depVersionMap := range l.InstallCache {
   327  		for _, depVersion := range depVersionMap {
   328  			return depVersion.StructVersion
   329  		}
   330  	}
   331  	// we have no deps - just return the new struct version
   332  	return WorkspaceLockStructVersion
   333  
   334  }
   335  
   336  func (l *WorkspaceLock) FindInstalledDependency(modDependency *ResolvedVersionConstraint) (string, error) {
   337  	dependencyFilepath := path.Join(l.ModInstallationPath, modDependency.DependencyPath())
   338  
   339  	if filehelpers.DirectoryExists(dependencyFilepath) {
   340  		return dependencyFilepath, nil
   341  	}
   342  
   343  	return "", fmt.Errorf("dependency mod '%s' is not installed - run 'steampipe mod install'", modDependency.DependencyPath())
   344  }