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

     1  package buildplanner
     2  
     3  import (
     4  	"errors"
     5  	"regexp"
     6  	"strconv"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/ActiveState/cli/internal/errs"
    11  	"github.com/ActiveState/cli/internal/gqlclient"
    12  	"github.com/ActiveState/cli/internal/locale"
    13  	"github.com/ActiveState/cli/internal/logging"
    14  	"github.com/ActiveState/cli/pkg/buildplan"
    15  	"github.com/ActiveState/cli/pkg/buildplan/raw"
    16  	"github.com/ActiveState/cli/pkg/platform/api/buildplanner/request"
    17  	"github.com/ActiveState/cli/pkg/platform/api/buildplanner/response"
    18  	"github.com/ActiveState/cli/pkg/platform/api/buildplanner/types"
    19  	"github.com/ActiveState/cli/pkg/platform/api/reqsimport"
    20  	"github.com/ActiveState/cli/pkg/platform/runtime/buildexpression"
    21  	"github.com/ActiveState/graphql"
    22  	"github.com/go-openapi/strfmt"
    23  )
    24  
    25  const (
    26  	pollInterval       = 1 * time.Second
    27  	pollTimeout        = 30 * time.Second
    28  	buildStatusTimeout = 24 * time.Hour
    29  
    30  	codeExtensionKey = "code"
    31  )
    32  
    33  type Commit struct {
    34  	*response.Commit
    35  	buildplan       *buildplan.BuildPlan
    36  	buildexpression *buildexpression.BuildExpression
    37  }
    38  
    39  func (c *Commit) BuildPlan() *buildplan.BuildPlan {
    40  	return c.buildplan
    41  }
    42  
    43  func (c *Commit) BuildExpression() *buildexpression.BuildExpression {
    44  	return c.buildexpression
    45  }
    46  
    47  func (c *client) Run(req gqlclient.Request, resp interface{}) error {
    48  	logRequestVariables(req)
    49  	return c.gqlClient.Run(req, resp)
    50  }
    51  
    52  func (b *BuildPlanner) FetchCommit(commitID strfmt.UUID, owner, project string, target *string) (*Commit, error) {
    53  	logging.Debug("FetchBuildResult, commitID: %s, owner: %s, project: %s", commitID, owner, project)
    54  	resp := &response.ProjectCommitResponse{}
    55  	err := b.client.Run(request.ProjectCommit(commitID.String(), owner, project, target), resp)
    56  	if err != nil {
    57  		return nil, processBuildPlannerError(err, "failed to fetch commit")
    58  	}
    59  
    60  	// The BuildPlanner will return a build plan with a status of
    61  	// "planning" if the build plan is not ready yet. We need to
    62  	// poll the BuildPlanner until the build is ready.
    63  	if resp.Project.Commit.Build.Status == raw.Planning {
    64  		resp.Project.Commit.Build, err = b.pollBuildPlanned(commitID.String(), owner, project, target)
    65  		if err != nil {
    66  			return nil, errs.Wrap(err, "failed to poll build plan")
    67  		}
    68  	}
    69  
    70  	commit := resp.Project.Commit
    71  
    72  	bp, err := buildplan.Unmarshal(commit.Build.RawMessage)
    73  	if err != nil {
    74  		return nil, errs.Wrap(err, "failed to unmarshal build plan")
    75  	}
    76  
    77  	expression, err := buildexpression.New(commit.Expression)
    78  	if err != nil {
    79  		return nil, errs.Wrap(err, "failed to parse build expression")
    80  	}
    81  
    82  	return &Commit{commit, bp, expression}, nil
    83  }
    84  
    85  // processBuildPlannerError will check for special error types that should be
    86  // handled differently. If no special error type is found, the fallback message
    87  // will be used.
    88  // It expects the errors field to be the top-level field in the response. This is
    89  // different from special error types that are returned as part of the data field.
    90  // Example:
    91  //
    92  //	{
    93  //	  "errors": [
    94  //	    {
    95  //	      "message": "deprecation error",
    96  //	      "locations": [
    97  //	        {
    98  //	          "line": 7,
    99  //	          "column": 11
   100  //	        }
   101  //	      ],
   102  //	      "path": [
   103  //	        "project",
   104  //	        "commit",
   105  //	        "build"
   106  //	      ],
   107  //	      "extensions": {
   108  //	        "code": "CLIENT_DEPRECATION_ERROR"
   109  //	      }
   110  //	    }
   111  //	  ],
   112  //	  "data": null
   113  //	}
   114  func processBuildPlannerError(bpErr error, fallbackMessage string) error {
   115  	graphqlErr := &graphql.GraphErr{}
   116  	if errors.As(bpErr, graphqlErr) {
   117  		code, ok := graphqlErr.Extensions[codeExtensionKey].(string)
   118  		if ok && code == clientDeprecationErrorKey {
   119  			return &response.BuildPlannerError{Err: locale.NewExternalError("err_buildplanner_deprecated", "Encountered deprecation error: {{.V0}}", graphqlErr.Message)}
   120  		}
   121  	}
   122  	return &response.BuildPlannerError{Err: locale.NewExternalError("err_buildplanner", "{{.V0}}: Encountered unexpected error: {{.V1}}", fallbackMessage, bpErr.Error())}
   123  }
   124  
   125  var versionRe = regexp.MustCompile(`^\d+(\.\d+)*$`)
   126  
   127  func isExactVersion(version string) bool {
   128  	return versionRe.MatchString(version)
   129  }
   130  
   131  func isWildcardVersion(version string) bool {
   132  	return strings.Contains(version, ".x") || strings.Contains(version, ".X")
   133  }
   134  
   135  func VersionStringToRequirements(version string) ([]types.VersionRequirement, error) {
   136  	if isExactVersion(version) {
   137  		return []types.VersionRequirement{{
   138  			types.VersionRequirementComparatorKey: "eq",
   139  			types.VersionRequirementVersionKey:    version,
   140  		}}, nil
   141  	}
   142  
   143  	if !isWildcardVersion(version) {
   144  		// Ask the Platform to translate a string like ">=1.2,<1.3" into a list of requirements.
   145  		// Note that:
   146  		// - The given requirement name does not matter; it is not looked up.
   147  		changeset, err := reqsimport.Init().Changeset([]byte("name "+version), "")
   148  		if err != nil {
   149  			return nil, locale.WrapInputError(err, "err_invalid_version_string", "Invalid version string")
   150  		}
   151  		requirements := []types.VersionRequirement{}
   152  		for _, change := range changeset {
   153  			for _, constraint := range change.VersionConstraints {
   154  				requirements = append(requirements, types.VersionRequirement{
   155  					types.VersionRequirementComparatorKey: constraint.Comparator,
   156  					types.VersionRequirementVersionKey:    constraint.Version,
   157  				})
   158  			}
   159  		}
   160  		return requirements, nil
   161  	}
   162  
   163  	// Construct version constraints to be >= given version, and < given version's last part + 1.
   164  	// For example, given a version number of 3.10.x, constraints should be >= 3.10, < 3.11.
   165  	// Given 2.x, constraints should be >= 2, < 3.
   166  	requirements := []types.VersionRequirement{}
   167  	parts := strings.Split(version, ".")
   168  	for i, part := range parts {
   169  		if part != "x" && part != "X" {
   170  			continue
   171  		}
   172  		if i == 0 {
   173  			return nil, locale.NewInputError("err_version_wildcard_start", "A version number cannot start with a wildcard")
   174  		}
   175  		requirements = append(requirements, types.VersionRequirement{
   176  			types.VersionRequirementComparatorKey: types.ComparatorGTE,
   177  			types.VersionRequirementVersionKey:    strings.Join(parts[:i], "."),
   178  		})
   179  		previousPart, err := strconv.Atoi(parts[i-1])
   180  		if err != nil {
   181  			return nil, locale.WrapInputError(err, "err_version_number_expected", "Version parts are expected to be numeric")
   182  		}
   183  		parts[i-1] = strconv.Itoa(previousPart + 1)
   184  		requirements = append(requirements, types.VersionRequirement{
   185  			types.VersionRequirementComparatorKey: types.ComparatorLT,
   186  			types.VersionRequirementVersionKey:    strings.Join(parts[:i], "."),
   187  		})
   188  	}
   189  	return requirements, nil
   190  }
   191  
   192  // pollBuildPlanned polls the buildplan until it has passed the planning stage (ie. it's either planned or further along).
   193  func (b *BuildPlanner) pollBuildPlanned(commitID, owner, project string, target *string) (*response.BuildResponse, error) {
   194  	resp := &response.ProjectCommitResponse{}
   195  	ticker := time.NewTicker(pollInterval)
   196  	for {
   197  		select {
   198  		case <-ticker.C:
   199  			err := b.client.Run(request.ProjectCommit(commitID, owner, project, target), resp)
   200  			if err != nil {
   201  				return nil, processBuildPlannerError(err, "failed to fetch commit during poll")
   202  			}
   203  
   204  			if resp == nil {
   205  				return nil, errs.New("Build plan response is nil")
   206  			}
   207  
   208  			build := resp.Project.Commit.Build
   209  
   210  			if build.Status != raw.Planning {
   211  				return build, nil
   212  			}
   213  		case <-time.After(pollTimeout):
   214  			return nil, locale.NewError("err_buildplanner_timeout", "Timed out waiting for build plan")
   215  		}
   216  	}
   217  }
   218  
   219  type ErrFailedArtifacts struct {
   220  	Artifacts map[strfmt.UUID]*response.ArtifactResponse
   221  }
   222  
   223  func (e ErrFailedArtifacts) Error() string {
   224  	return "ErrFailedArtifacts"
   225  }
   226  
   227  // WaitForBuild polls the build until it has passed the completed stage (ie. it's either successful or failed).
   228  func (b *BuildPlanner) WaitForBuild(commitID strfmt.UUID, owner, project string, target *string) error {
   229  	failedArtifacts := map[strfmt.UUID]*response.ArtifactResponse{}
   230  	resp := &response.ProjectCommitResponse{}
   231  	ticker := time.NewTicker(pollInterval)
   232  	for {
   233  		select {
   234  		case <-ticker.C:
   235  			err := b.client.Run(request.ProjectCommit(commitID.String(), owner, project, target), resp)
   236  			if err != nil {
   237  				return processBuildPlannerError(err, "failed to fetch commit while waiting for completed build")
   238  			}
   239  
   240  			if resp == nil {
   241  				return errs.New("Build plan response is nil")
   242  			}
   243  
   244  			build := resp.Project.Commit.Build
   245  
   246  			// If the build status is planning it may not have any artifacts yet.
   247  			if build.Status == raw.Planning {
   248  				continue
   249  			}
   250  
   251  			// If all artifacts are completed then we are done.
   252  			completed := true
   253  			for _, artifact := range build.Artifacts {
   254  				if artifact.Status == types.ArtifactNotSubmitted {
   255  					continue
   256  				}
   257  				if artifact.Status != types.ArtifactSucceeded {
   258  					completed = false
   259  				}
   260  
   261  				if artifact.Status == types.ArtifactFailedPermanently ||
   262  					artifact.Status == types.ArtifactFailedTransiently {
   263  					failedArtifacts[artifact.NodeID] = &artifact
   264  				}
   265  			}
   266  
   267  			if completed {
   268  				return nil
   269  			}
   270  
   271  			// If the build status is completed then we are done.
   272  			if build.Status == raw.Completed {
   273  				if len(failedArtifacts) != 0 {
   274  					return ErrFailedArtifacts{failedArtifacts}
   275  				}
   276  				return nil
   277  			}
   278  		case <-time.After(buildStatusTimeout):
   279  			return locale.NewError("err_buildplanner_timeout", "Timed out waiting for build plan")
   280  		}
   281  	}
   282  }