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 }