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

     1  package runtime
     2  
     3  import (
     4  	"errors"
     5  	"os"
     6  	"path/filepath"
     7  	"strings"
     8  
     9  	"github.com/ActiveState/cli/pkg/buildplan"
    10  	bpResp "github.com/ActiveState/cli/pkg/platform/api/buildplanner/response"
    11  	bpModel "github.com/ActiveState/cli/pkg/platform/model/buildplanner"
    12  	"golang.org/x/net/context"
    13  
    14  	"github.com/ActiveState/cli/internal/analytics"
    15  	anaConsts "github.com/ActiveState/cli/internal/analytics/constants"
    16  	"github.com/ActiveState/cli/internal/analytics/dimensions"
    17  	"github.com/ActiveState/cli/internal/constants"
    18  	"github.com/ActiveState/cli/internal/errs"
    19  	"github.com/ActiveState/cli/internal/fileutils"
    20  	"github.com/ActiveState/cli/internal/installation/storage"
    21  	"github.com/ActiveState/cli/internal/instanceid"
    22  	"github.com/ActiveState/cli/internal/locale"
    23  	"github.com/ActiveState/cli/internal/logging"
    24  	"github.com/ActiveState/cli/internal/multilog"
    25  	"github.com/ActiveState/cli/internal/osutils"
    26  	"github.com/ActiveState/cli/internal/output"
    27  	"github.com/ActiveState/cli/internal/rtutils/ptr"
    28  	"github.com/ActiveState/cli/pkg/platform/authentication"
    29  	"github.com/ActiveState/cli/pkg/platform/model"
    30  	"github.com/ActiveState/cli/pkg/platform/runtime/buildscript"
    31  	"github.com/ActiveState/cli/pkg/platform/runtime/envdef"
    32  	"github.com/ActiveState/cli/pkg/platform/runtime/setup"
    33  	"github.com/ActiveState/cli/pkg/platform/runtime/setup/buildlog"
    34  	"github.com/ActiveState/cli/pkg/platform/runtime/setup/events"
    35  	"github.com/ActiveState/cli/pkg/platform/runtime/store"
    36  	"github.com/ActiveState/cli/pkg/project"
    37  )
    38  
    39  type Configurable interface {
    40  	GetString(key string) string
    41  	GetBool(key string) bool
    42  }
    43  
    44  type Runtime struct {
    45  	disabled  bool
    46  	target    setup.Targeter
    47  	store     *store.Store
    48  	analytics analytics.Dispatcher
    49  	svcm      *model.SvcModel
    50  	auth      *authentication.Auth
    51  	completed bool
    52  	cfg       Configurable
    53  	out       output.Outputer
    54  }
    55  
    56  // NeedsCommitError is an error returned when the local runtime's build script has changes that need
    57  // staging. This is not a fatal error. A runtime can still be used, but a warning should be emitted.
    58  var NeedsCommitError = errors.New("runtime needs commit")
    59  
    60  // NeedsBuildscriptResetError is an error returned when the runtime is improperly referenced in the project (eg. missing buildscript)
    61  var NeedsBuildscriptResetError = errors.New("needs runtime reset")
    62  
    63  func newRuntime(target setup.Targeter, an analytics.Dispatcher, svcModel *model.SvcModel, auth *authentication.Auth, cfg Configurable, out output.Outputer) (*Runtime, error) {
    64  	rt := &Runtime{
    65  		target:    target,
    66  		store:     store.New(target.Dir()),
    67  		analytics: an,
    68  		svcm:      svcModel,
    69  		auth:      auth,
    70  		cfg:       cfg,
    71  		out:       out,
    72  	}
    73  
    74  	err := rt.validateCache()
    75  	if err != nil {
    76  		return rt, err
    77  	}
    78  
    79  	return rt, nil
    80  }
    81  
    82  // New attempts to create a new runtime from local storage.
    83  func New(target setup.Targeter, an analytics.Dispatcher, svcm *model.SvcModel, auth *authentication.Auth, cfg Configurable, out output.Outputer) (*Runtime, error) {
    84  	logging.Debug("Initializing runtime for: %s/%s@%s", target.Owner(), target.Name(), target.CommitUUID())
    85  
    86  	if strings.ToLower(os.Getenv(constants.DisableRuntime)) == "true" {
    87  		out.Notice(locale.T("notice_runtime_disabled"))
    88  		return &Runtime{disabled: true, target: target, analytics: an}, nil
    89  	}
    90  	recordAttempt(an, target)
    91  	an.Event(anaConsts.CatRuntimeDebug, anaConsts.ActRuntimeStart, &dimensions.Values{
    92  		Trigger:          ptr.To(target.Trigger().String()),
    93  		CommitID:         ptr.To(target.CommitUUID().String()),
    94  		ProjectNameSpace: ptr.To(project.NewNamespace(target.Owner(), target.Name(), target.CommitUUID().String()).String()),
    95  		InstanceID:       ptr.To(instanceid.ID()),
    96  	})
    97  
    98  	r, err := newRuntime(target, an, svcm, auth, cfg, out)
    99  	if err == nil {
   100  		an.Event(anaConsts.CatRuntimeDebug, anaConsts.ActRuntimeCache, &dimensions.Values{
   101  			CommitID: ptr.To(target.CommitUUID().String()),
   102  		})
   103  	}
   104  
   105  	return r, err
   106  }
   107  
   108  func (r *Runtime) NeedsUpdate() bool {
   109  	if strings.ToLower(os.Getenv(constants.DisableRuntime)) == "true" {
   110  		return false
   111  	}
   112  	if !r.store.MarkerIsValid(r.target.CommitUUID()) {
   113  		if r.target.ReadOnly() {
   114  			logging.Debug("Using forced cache")
   115  		} else {
   116  			return true
   117  		}
   118  	}
   119  	return false
   120  }
   121  
   122  func (r *Runtime) validateCache() error {
   123  	if r.target.ProjectDir() == "" {
   124  		return nil
   125  	}
   126  
   127  	err := r.validateBuildScript()
   128  	if err != nil {
   129  		return errs.Wrap(err, "Error validating build script")
   130  	}
   131  
   132  	return nil
   133  }
   134  
   135  // validateBuildScript asserts the local build script does not have changes that should be committed.
   136  func (r *Runtime) validateBuildScript() error {
   137  	logging.Debug("Checking to see if local build script has changes that should be committed")
   138  	if !r.cfg.GetBool(constants.OptinBuildscriptsConfig) {
   139  		logging.Debug("Not opted into buildscripts")
   140  		return nil
   141  	}
   142  
   143  	script, err := buildscript.ScriptFromProject(r.target)
   144  	if err != nil {
   145  		if errors.Is(err, buildscript.ErrBuildscriptNotExist) {
   146  			return errs.Pack(err, NeedsBuildscriptResetError)
   147  		}
   148  		return errs.Wrap(err, "Could not get buildscript from project")
   149  	}
   150  
   151  	cachedCommitID, err := r.store.CommitID()
   152  	if err != nil {
   153  		logging.Debug("No commit ID to read; refresh needed")
   154  		return nil
   155  	}
   156  
   157  	if cachedCommitID != r.target.CommitUUID().String() {
   158  		logging.Debug("Runtime commit ID does not match project commit ID; refresh needed")
   159  		return nil
   160  	}
   161  
   162  	cachedScript, err := r.store.BuildScript()
   163  	if err != nil {
   164  		if errors.Is(err, store.ErrNoBuildScriptFile) {
   165  			logging.Warning("No buildscript file exists in store, unable to check if buildscript is dirty. This can happen if you cleared your cache.")
   166  		} else {
   167  			return errs.Wrap(err, "Could not retrieve buildscript from store")
   168  		}
   169  	}
   170  
   171  	if cachedScript != nil {
   172  		if script != nil && !script.Equals(cachedScript) {
   173  			return NeedsCommitError
   174  		}
   175  	}
   176  
   177  	return nil
   178  }
   179  
   180  func (r *Runtime) Disabled() bool {
   181  	return r.disabled
   182  }
   183  
   184  func (r *Runtime) Target() setup.Targeter {
   185  	return r.target
   186  }
   187  
   188  func (r *Runtime) Setup(eventHandler events.Handler) *setup.Setup {
   189  	return setup.New(r.target, eventHandler, r.auth, r.analytics, r.cfg, r.out, r.svcm)
   190  }
   191  
   192  func (r *Runtime) Update(setup *setup.Setup, commit *bpModel.Commit) (rerr error) {
   193  	if r.disabled {
   194  		logging.Debug("Skipping update as it is disabled")
   195  		return nil // nothing to do
   196  	}
   197  
   198  	logging.Debug("Updating %s#%s @ %s", r.target.Name(), r.target.CommitUUID(), r.target.Dir())
   199  
   200  	defer func() {
   201  		r.recordCompletion(rerr)
   202  	}()
   203  
   204  	if err := setup.Update(commit); err != nil {
   205  		return errs.Wrap(err, "Update failed")
   206  	}
   207  
   208  	// Reinitialize
   209  	rt, err := newRuntime(r.target, r.analytics, r.svcm, r.auth, r.cfg, r.out)
   210  	if err != nil {
   211  		return errs.Wrap(err, "Could not reinitialize runtime after update")
   212  	}
   213  	*r = *rt
   214  
   215  	return nil
   216  }
   217  
   218  // SolveAndUpdate updates the runtime by downloading all necessary artifacts from the Platform and installing them locally.
   219  func (r *Runtime) SolveAndUpdate(eventHandler events.Handler) error {
   220  	if r.disabled {
   221  		logging.Debug("Skipping update as it is disabled")
   222  		return nil // nothing to do
   223  	}
   224  
   225  	setup := r.Setup(eventHandler)
   226  	commit, err := setup.Solve()
   227  	if err != nil {
   228  		return errs.Wrap(err, "Could not solve")
   229  	}
   230  
   231  	if err := r.Update(setup, commit); err != nil {
   232  		return errs.Wrap(err, "Could not update")
   233  	}
   234  
   235  	return nil
   236  }
   237  
   238  // HasCache tells us whether this runtime has any cached files. Note this does NOT tell you whether the cache is valid.
   239  func (r *Runtime) HasCache() bool {
   240  	return fileutils.DirExists(r.target.Dir())
   241  }
   242  
   243  // Env returns a key-value map of the environment variables that need to be set for this runtime
   244  // It's different from envDef in that it merges in the current active environment and points the PATH variable to the
   245  // Executors directory if requested
   246  func (r *Runtime) Env(inherit bool, useExecutors bool) (map[string]string, error) {
   247  	logging.Debug("Getting runtime env, inherit: %v, useExec: %v", inherit, useExecutors)
   248  
   249  	envDef, err := r.envDef()
   250  	r.recordCompletion(err)
   251  	if err != nil {
   252  		return nil, errs.Wrap(err, "Could not grab environment definitions")
   253  	}
   254  
   255  	env := envDef.GetEnv(inherit)
   256  
   257  	execDir := filepath.Clean(setup.ExecDir(r.target.Dir()))
   258  	if useExecutors {
   259  		// Override PATH entry with exec path
   260  		pathEntries := []string{execDir}
   261  		if inherit {
   262  			pathEntries = append(pathEntries, os.Getenv("PATH"))
   263  		}
   264  		env["PATH"] = strings.Join(pathEntries, string(os.PathListSeparator))
   265  	} else {
   266  		// Ensure we aren't inheriting the executor paths from something like an activated state
   267  		envdef.FilterPATH(env, execDir, storage.GlobalBinDir())
   268  	}
   269  
   270  	return env, nil
   271  }
   272  
   273  func (r *Runtime) recordCompletion(err error) {
   274  	if r.completed {
   275  		logging.Debug("Not recording runtime completion as it was already recorded for this invocation")
   276  		return
   277  	}
   278  	r.completed = true
   279  	logging.Debug("Recording runtime completion, error: %v", err == nil)
   280  
   281  	var action string
   282  	if err != nil {
   283  		action = anaConsts.ActRuntimeFailure
   284  	} else {
   285  		action = anaConsts.ActRuntimeSuccess
   286  		r.recordUsage()
   287  	}
   288  
   289  	ns := project.Namespaced{
   290  		Owner:   r.target.Owner(),
   291  		Project: r.target.Name(),
   292  	}
   293  
   294  	errorType := "unknown"
   295  	switch {
   296  	// IsInputError should always be first because it is technically possible for something like a
   297  	// download error to be cause by an input error.
   298  	case locale.IsInputError(err):
   299  		errorType = "input"
   300  	case errs.Matches(err, &setup.BuildError{}), errs.Matches(err, &buildlog.BuildError{}):
   301  		errorType = "build"
   302  	case errs.Matches(err, &bpResp.BuildPlannerError{}):
   303  		errorType = "buildplan"
   304  	case errs.Matches(err, &setup.ArtifactSetupErrors{}):
   305  		if setupErrors := (&setup.ArtifactSetupErrors{}); errors.As(err, &setupErrors) {
   306  			// Label the loop so we can break out of it when we find the first download
   307  			// or build error.
   308  		Loop:
   309  			for _, err := range setupErrors.Errors() {
   310  				switch {
   311  				case errs.Matches(err, &setup.ArtifactDownloadError{}):
   312  					errorType = "download"
   313  					break Loop // it only takes one download failure to report the runtime failure as due to download error
   314  				case errs.Matches(err, &setup.ArtifactInstallError{}):
   315  					errorType = "install"
   316  					// Note: do not break because there could be download errors, and those take precedence
   317  				case errs.Matches(err, &setup.BuildError{}), errs.Matches(err, &buildlog.BuildError{}):
   318  					errorType = "build"
   319  					break Loop // it only takes one build failure to report the runtime failure as due to build error
   320  				}
   321  			}
   322  		}
   323  	// Progress/event handler errors should come last because they can wrap one of the above errors,
   324  	// and those errors actually caused the failure, not these.
   325  	case errs.Matches(err, &setup.ProgressReportError{}) || errs.Matches(err, &buildlog.EventHandlerError{}):
   326  		errorType = "progress"
   327  	case errs.Matches(err, &setup.ExecutorSetupError{}):
   328  		errorType = "postprocess"
   329  	}
   330  
   331  	var message string
   332  	if err != nil {
   333  		message = errs.JoinMessage(err)
   334  	}
   335  
   336  	r.analytics.Event(anaConsts.CatRuntimeDebug, action, &dimensions.Values{
   337  		CommitID: ptr.To(r.target.CommitUUID().String()),
   338  		// Note: ProjectID is set by state-svc since ProjectNameSpace is specified.
   339  		ProjectNameSpace: ptr.To(ns.String()),
   340  		Error:            ptr.To(errorType),
   341  		Message:          &message,
   342  	})
   343  }
   344  
   345  func (r *Runtime) recordUsage() {
   346  	if !r.target.Trigger().IndicatesUsage() {
   347  		logging.Debug("Not recording usage as %s is not a usage trigger", r.target.Trigger().String())
   348  		return
   349  	}
   350  
   351  	// Fire initial runtime usage event right away, subsequent events will be fired via the service so long as the process is running
   352  	dims := usageDims(r.target)
   353  	dimsJson, err := dims.Marshal()
   354  	if err != nil {
   355  		multilog.Critical("Could not marshal dimensions for runtime-usage: %s", errs.JoinMessage(err))
   356  	}
   357  	if r.svcm != nil {
   358  		if err := r.svcm.ReportRuntimeUsage(context.Background(), os.Getpid(), osutils.Executable(), anaConsts.SrcStateTool, dimsJson); err != nil {
   359  			multilog.Critical("Could not report runtime usage: %s", errs.JoinMessage(err))
   360  		}
   361  	}
   362  }
   363  
   364  func recordAttempt(an analytics.Dispatcher, target setup.Targeter) {
   365  	if !target.Trigger().IndicatesUsage() {
   366  		logging.Debug("Not recording usage attempt as %s is not a usage trigger", target.Trigger().String())
   367  		return
   368  	}
   369  
   370  	an.Event(anaConsts.CatRuntimeUsage, anaConsts.ActRuntimeAttempt, usageDims(target))
   371  }
   372  
   373  func usageDims(target setup.Targeter) *dimensions.Values {
   374  	return &dimensions.Values{
   375  		Trigger:          ptr.To(target.Trigger().String()),
   376  		CommitID:         ptr.To(target.CommitUUID().String()),
   377  		ProjectNameSpace: ptr.To(project.NewNamespace(target.Owner(), target.Name(), target.CommitUUID().String()).String()),
   378  		InstanceID:       ptr.To(instanceid.ID()),
   379  	}
   380  }
   381  
   382  func (r *Runtime) envDef() (*envdef.EnvironmentDefinition, error) {
   383  	if r.disabled {
   384  		return nil, errs.New("Called envDef() on a disabled runtime.")
   385  	}
   386  	env, err := r.store.EnvDef()
   387  	if err != nil {
   388  		return nil, errs.Wrap(err, "store.EnvDef failed")
   389  	}
   390  	return env, nil
   391  }
   392  
   393  func (r *Runtime) ExecutablePaths() (envdef.ExecutablePaths, error) {
   394  	env, err := r.envDef()
   395  	if err != nil {
   396  		return nil, errs.Wrap(err, "Could not retrieve environment info")
   397  	}
   398  	return env.ExecutablePaths()
   399  }
   400  
   401  func (r *Runtime) ExecutableDirs() (envdef.ExecutablePaths, error) {
   402  	env, err := r.envDef()
   403  	if err != nil {
   404  		return nil, errs.Wrap(err, "Could not retrieve environment info")
   405  	}
   406  	return env.ExecutableDirs()
   407  }
   408  
   409  func IsRuntimeDir(dir string) bool {
   410  	return store.New(dir).HasMarker()
   411  }
   412  
   413  func (r *Runtime) BuildPlan() (*buildplan.BuildPlan, error) {
   414  	runtimeStore := r.store
   415  	if runtimeStore == nil {
   416  		runtimeStore = store.New(r.target.Dir())
   417  	}
   418  	plan, err := runtimeStore.BuildPlan()
   419  	if err != nil {
   420  		return nil, errs.Wrap(err, "Unable to fetch build plan")
   421  	}
   422  	return plan, nil
   423  }