v.io/jiri@v0.0.0-20160715023856-abfb8b131290/jenkins/jenkins.go (about) 1 // Copyright 2015 The Vanadium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package jenkins 6 7 import ( 8 "encoding/json" 9 "fmt" 10 "io" 11 "io/ioutil" 12 "net/http" 13 "net/url" 14 "regexp" 15 "strconv" 16 "strings" 17 18 "v.io/jiri/collect" 19 ) 20 21 func New(host string) (*Jenkins, error) { 22 j := &Jenkins{ 23 host: host, 24 } 25 return j, nil 26 } 27 28 // NewForTesting creates a Jenkins instance in test mode. 29 func NewForTesting() *Jenkins { 30 return &Jenkins{ 31 testMode: true, 32 invokeMockResults: map[string][]byte{}, 33 } 34 } 35 36 type Jenkins struct { 37 host string 38 39 // The following fields are for testing only. 40 41 // testMode indicates whether this Jenkins instance is in test mode. 42 testMode bool 43 44 // invokeMockResults maps from API suffix to a mock result. 45 // In test mode, the mock result will be returned when "invoke" is called. 46 invokeMockResults map[string][]byte 47 } 48 49 // MockAPI mocks "invoke" with the given API suffix. 50 func (j *Jenkins) MockAPI(suffix, result string) { 51 j.invokeMockResults[suffix] = []byte(result) 52 } 53 54 type QueuedBuild struct { 55 Id int 56 Params string `json:"params,omitempty"` 57 Task QueuedBuildTask 58 } 59 60 type QueuedBuildTask struct { 61 Name string 62 } 63 64 // ParseRefs parses refs from a QueuedBuild object's Params field. 65 func (qb *QueuedBuild) ParseRefs() string { 66 // The params string is in the form of: 67 // "\nREFS=ref/changes/12/3412/2\nPROJECTS=test" or 68 // "\nPROJECTS=test\nREFS=ref/changes/12/3412/2" 69 parts := strings.Split(qb.Params, "\n") 70 refs := "" 71 refsPrefix := "REFS=" 72 for _, part := range parts { 73 if strings.HasPrefix(part, refsPrefix) { 74 refs = strings.TrimPrefix(part, refsPrefix) 75 break 76 } 77 } 78 return refs 79 } 80 81 // QueuedBuilds returns the queued builds. 82 func (j *Jenkins) QueuedBuilds(jobName string) (_ []QueuedBuild, err error) { 83 // Get queued builds. 84 bytes, err := j.invoke("GET", "queue/api/json", url.Values{}) 85 if err != nil { 86 return nil, err 87 } 88 var builds struct { 89 Items []QueuedBuild 90 } 91 if err := json.Unmarshal(bytes, &builds); err != nil { 92 return nil, fmt.Errorf("Unmarshal() failed: %v\n%s", err, string(bytes)) 93 } 94 95 // Filter for jobName. 96 queuedBuildsForJob := []QueuedBuild{} 97 for _, build := range builds.Items { 98 if build.Task.Name != jobName { 99 continue 100 } 101 queuedBuildsForJob = append(queuedBuildsForJob, build) 102 } 103 return queuedBuildsForJob, nil 104 } 105 106 type BuildInfo struct { 107 Actions []BuildInfoAction 108 Building bool 109 Number int 110 Result string 111 Id string 112 Timestamp int64 113 } 114 115 type BuildInfoAction struct { 116 Parameters []BuildInfoParameter 117 } 118 119 type BuildInfoParameter struct { 120 Name string 121 Value string 122 } 123 124 // ParseRefs parses the REFS parameter from a BuildInfo object. 125 func (bi *BuildInfo) ParseRefs() string { 126 refs := "" 127 loop: 128 for _, action := range bi.Actions { 129 for _, param := range action.Parameters { 130 if param.Name == "REFS" { 131 refs = param.Value 132 break loop 133 } 134 } 135 } 136 return refs 137 } 138 139 // OngoingBuilds returns a slice of BuildInfo for current ongoing builds 140 // for the given job. 141 func (j *Jenkins) OngoingBuilds(jobName string) (_ []BuildInfo, err error) { 142 // Get urls of all ongoing builds. 143 bytes, err := j.invoke("GET", "computer/api/json", url.Values{ 144 "tree": {"computer[executors[currentExecutable[url]],oneOffExecutors[currentExecutable[url]]]"}, 145 }) 146 if err != nil { 147 return nil, err 148 } 149 var computers struct { 150 Computer []struct { 151 Executors []struct { 152 CurrentExecutable struct { 153 Url string 154 } 155 } 156 OneOffExecutors []struct { 157 CurrentExecutable struct { 158 Url string 159 } 160 } 161 } 162 } 163 if err := json.Unmarshal(bytes, &computers); err != nil { 164 return nil, fmt.Errorf("Unmarshal() failed: %v\n%s", err, string(bytes)) 165 } 166 urls := []string{} 167 for _, computer := range computers.Computer { 168 for _, executor := range computer.Executors { 169 curUrl := executor.CurrentExecutable.Url 170 if curUrl != "" { 171 urls = append(urls, curUrl) 172 } 173 } 174 for _, oneOffExecutor := range computer.OneOffExecutors { 175 curUrl := oneOffExecutor.CurrentExecutable.Url 176 if curUrl != "" { 177 urls = append(urls, curUrl) 178 } 179 } 180 } 181 182 buildInfos := []BuildInfo{} 183 masterJobURLRE := regexp.MustCompile(fmt.Sprintf(`.*/%s/(\d+)/$`, jobName)) 184 for _, curUrl := range urls { 185 // Filter for jobName, and get the build number. 186 matches := masterJobURLRE.FindStringSubmatch(curUrl) 187 if matches == nil { 188 continue 189 } 190 strBuildNumber := matches[1] 191 buildNumber, err := strconv.Atoi(strBuildNumber) 192 if err != nil { 193 return nil, fmt.Errorf("Atoi(%s) failed: %v", strBuildNumber, err) 194 } 195 buildInfo, err := j.BuildInfo(jobName, buildNumber) 196 if err != nil { 197 return nil, err 198 } 199 buildInfos = append(buildInfos, *buildInfo) 200 } 201 return buildInfos, nil 202 } 203 204 // BuildInfo returns a build's info for the given jobName and buildNumber. 205 func (j *Jenkins) BuildInfo(jobName string, buildNumber int) (*BuildInfo, error) { 206 buildSpec := fmt.Sprintf("%s/%d", jobName, buildNumber) 207 return j.BuildInfoForSpec(buildSpec) 208 } 209 210 // BuildInfoWithBuildURL returns a build's info for the given build's URL. 211 func (j *Jenkins) BuildInfoForSpec(buildSpec string) (*BuildInfo, error) { 212 getBuildInfoUri := fmt.Sprintf("job/%s/api/json", buildSpec) 213 bytes, err := j.invoke("GET", getBuildInfoUri, url.Values{}) 214 if err != nil { 215 return nil, err 216 } 217 var buildInfo BuildInfo 218 if err := json.Unmarshal(bytes, &buildInfo); err != nil { 219 return nil, fmt.Errorf("Unmarshal() failed: %v\n%s", err, string(bytes)) 220 } 221 return &buildInfo, nil 222 } 223 224 // AddBuild adds a build to the given job. 225 func (j *Jenkins) AddBuild(jobName string) error { 226 addBuildUri := fmt.Sprintf("job/%s/build", jobName) 227 _, err := j.invoke("POST", addBuildUri, url.Values{}) 228 if err != nil { 229 return err 230 } 231 return nil 232 } 233 234 // AddBuildWithParameter adds a parameterized build to the given job. 235 func (j *Jenkins) AddBuildWithParameter(jobName string, params url.Values) error { 236 addBuildUri := fmt.Sprintf("job/%s/buildWithParameters", jobName) 237 _, err := j.invoke("POST", addBuildUri, params) 238 if err != nil { 239 return err 240 } 241 return nil 242 } 243 244 // CancelQueuedBuild cancels the queued build by given id. 245 func (j *Jenkins) CancelQueuedBuild(id string) error { 246 cancelQueuedBuildUri := "queue/cancelItem" 247 if _, err := j.invoke("POST", cancelQueuedBuildUri, url.Values{ 248 "id": {id}, 249 }); err != nil { 250 return err 251 } 252 return nil 253 } 254 255 // CancelOngoingBuild cancels the ongoing build by given jobName and buildNumber. 256 func (j *Jenkins) CancelOngoingBuild(jobName string, buildNumber int) error { 257 cancelOngoingBuildUri := fmt.Sprintf("job/%s/%d/stop", jobName, buildNumber) 258 if _, err := j.invoke("POST", cancelOngoingBuildUri, url.Values{}); err != nil { 259 return err 260 } 261 return nil 262 } 263 264 type TestCase struct { 265 ClassName string 266 Name string 267 Status string 268 } 269 270 func (t TestCase) Equal(t2 TestCase) bool { 271 return t.ClassName == t2.ClassName && t.Name == t2.Name 272 } 273 274 // FailedTestCasesForBuildSpec returns failed test cases for the given build spec. 275 func (j *Jenkins) FailedTestCasesForBuildSpec(buildSpec string) ([]TestCase, error) { 276 failedTestCases := []TestCase{} 277 278 // Get all test cases. 279 getTestReportUri := fmt.Sprintf("job/%s/testReport/api/json", buildSpec) 280 bytes, err := j.invoke("GET", getTestReportUri, url.Values{}) 281 if err != nil { 282 return failedTestCases, err 283 } 284 var testCases struct { 285 Suites []struct { 286 Cases []TestCase 287 } 288 } 289 if err := json.Unmarshal(bytes, &testCases); err != nil { 290 return failedTestCases, fmt.Errorf("Unmarshal(%v) failed: %v", string(bytes), err) 291 } 292 293 // Filter failed tests. 294 for _, suite := range testCases.Suites { 295 for _, curCase := range suite.Cases { 296 if curCase.Status == "FAILED" || curCase.Status == "REGRESSION" { 297 failedTestCases = append(failedTestCases, curCase) 298 } 299 } 300 } 301 return failedTestCases, nil 302 } 303 304 // JenkinsMachines stores information about Jenkins machines. 305 type JenkinsMachines struct { 306 Machines []JenkinsMachine `json:"computer"` 307 } 308 309 // JenkinsMachine stores information about a Jenkins machine. 310 type JenkinsMachine struct { 311 Name string `json:"displayName"` 312 Idle bool `json:"idle"` 313 } 314 315 // IsNodeIdle checks whether the given node is idle. 316 func (j *Jenkins) IsNodeIdle(node string) (bool, error) { 317 bytes, err := j.invoke("GET", "computer/api/json", url.Values{}) 318 if err != nil { 319 return false, err 320 } 321 machines := JenkinsMachines{} 322 if err := json.Unmarshal(bytes, &machines); err != nil { 323 return false, fmt.Errorf("Unmarshal() failed: %v\n%s\n", err, string(bytes)) 324 } 325 for _, machine := range machines.Machines { 326 if machine.Name == node { 327 return machine.Idle, nil 328 } 329 } 330 return false, fmt.Errorf("node %v not found", node) 331 } 332 333 // createRequest represents a request to create a new machine in 334 // Jenkins configuration. 335 type createRequest struct { 336 Name string `json:"name"` 337 Description string `json:"nodeDescription"` 338 NumExecutors int `json:"numExecutors"` 339 RemoteFS string `json:"remoteFS"` 340 Labels string `json:"labelString"` 341 Mode string `json:"mode"` 342 Type string `json:"type"` 343 RetentionStrategy map[string]string `json:"retentionStrategy"` 344 NodeProperties nodeProperties `json:"nodeProperties"` 345 Launcher map[string]string `json:"launcher"` 346 } 347 348 // nodeProperties enumerates the environment variable settings for 349 // Jenkins configuration. 350 type nodeProperties struct { 351 Class string `json:"stapler-class"` 352 Environment []map[string]string `json:"env"` 353 } 354 355 // AddNodeToJenkins sends an HTTP request to Jenkins that prompts it 356 // to add a new machine to its configuration. 357 // 358 // NOTE: Jenkins REST API is not documented anywhere and the 359 // particular HTTP request used to add a new machine to Jenkins 360 // configuration has been crafted using trial and error. 361 func (j *Jenkins) AddNodeToJenkins(name, host, description, credentialsId string) error { 362 request := createRequest{ 363 Name: name, 364 Description: description, 365 NumExecutors: 1, 366 RemoteFS: "/home/veyron/jenkins", 367 Labels: fmt.Sprintf("%s linux", name), 368 Mode: "EXCLUSIVE", 369 Type: "hudson.slaves.DumbSlave$DescriptorImpl", 370 RetentionStrategy: map[string]string{"stapler-class": "hudson.slaves.RetentionStrategy$Always"}, 371 NodeProperties: nodeProperties{ 372 Class: "hudson.slaves.EnvironmentVariablesNodeProperty", 373 Environment: []map[string]string{ 374 map[string]string{ 375 "stapler-class": "hudson.slaves.EnvironmentVariablesNodeProperty$Entry", 376 "key": "GOROOT", 377 "value": "$HOME/go", 378 }, 379 map[string]string{ 380 "stapler-class": "hudson.slaves.EnvironmentVariablesNodeProperty$Entry", 381 "key": "PATH", 382 "value": "$HOME/go/bin:$PATH", 383 }, 384 map[string]string{ 385 "stapler-class": "hudson.slaves.EnvironmentVariablesNodeProperty$Entry", 386 "key": "TERM", 387 "value": "xterm-256color", 388 }, 389 }, 390 }, 391 Launcher: map[string]string{ 392 "stapler-class": "hudson.plugins.sshslaves.SSHLauncher", 393 "host": host, 394 // The following ID can be retrieved from Jenkins configuration backup. 395 "credentialsId": credentialsId, 396 }, 397 } 398 bytes, err := json.Marshal(request) 399 if err != nil { 400 return fmt.Errorf("Marshal(%v) failed: %v", request, err) 401 } 402 values := url.Values{ 403 "name": {name}, 404 "type": {"hudson.slaves.DumbSlave$DescriptorImpl"}, 405 "json": {string(bytes)}, 406 } 407 _, err = j.invoke("GET", "computer/doCreateItem", values) 408 if err != nil { 409 return err 410 } 411 return nil 412 } 413 414 // RemoveNodeFromJenkins sends an HTTP request to Jenkins that prompts 415 // it to remove an existing machine from its configuration. 416 func (j *Jenkins) RemoveNodeFromJenkins(node string) error { 417 _, err := j.invoke("POST", fmt.Sprintf("computer/%s/doDelete", node), url.Values{}) 418 if err != nil { 419 return err 420 } 421 return nil 422 } 423 424 // invoke invokes the Jenkins API using the given suffix, values and 425 // HTTP method. 426 func (j *Jenkins) invoke(method, suffix string, values url.Values) (_ []byte, err error) { 427 // Return mock result in test mode. 428 if j.testMode { 429 return j.invokeMockResults[suffix], nil 430 } 431 432 apiURL, err := url.Parse(j.host) 433 if err != nil { 434 return nil, fmt.Errorf("Parse(%q) failed: %v", j.host, err) 435 } 436 apiURL.Path = fmt.Sprintf("%s/%s", apiURL.Path, suffix) 437 apiURL.RawQuery = values.Encode() 438 var body io.Reader 439 url, body := apiURL.String(), nil 440 req, err := http.NewRequest(method, url, body) 441 if err != nil { 442 return nil, fmt.Errorf("NewRequest(%q, %q, %v) failed: %v", method, url, body, err) 443 } 444 req.Header.Add("Accept", "application/json") 445 res, err := http.DefaultClient.Do(req) 446 if err != nil { 447 return nil, fmt.Errorf("Do(%v) failed: %v", req, err) 448 } 449 defer collect.Error(func() error { return res.Body.Close() }, &err) 450 bytes, err := ioutil.ReadAll(res.Body) 451 if err != nil { 452 return nil, err 453 } 454 // queue/cancelItem API returns 404 even successful. 455 // See: https://issues.jenkins-ci.org/browse/JENKINS-21311. 456 if suffix != "queue/cancelItem" && res.StatusCode >= http.StatusBadRequest { 457 return nil, fmt.Errorf("HTTP request %q returned %d:\n%s", url, res.StatusCode, string(bytes)) 458 } 459 return bytes, nil 460 } 461 462 // GenBuildSpec returns a spec string for the given Jenkins build. 463 // 464 // If the main job is a multi-configuration job, the spec is in the form of: 465 // <jobName>/axis1Label=axis1Value,axis2Label=axis2Value,.../<suffix> 466 // The axis values are taken from the given axisValues map. 467 // 468 // If no axisValues are provides, the spec will be: <jobName>/<suffix>. 469 func GenBuildSpec(jobName string, axisValues map[string]string, suffix string) string { 470 if len(axisValues) == 0 { 471 return fmt.Sprintf("%s/%s", jobName, suffix) 472 } 473 474 parts := []string{} 475 for k, v := range axisValues { 476 parts = append(parts, fmt.Sprintf("%s=%s", k, v)) 477 } 478 return fmt.Sprintf("%s/%s/%s", jobName, strings.Join(parts, ","), suffix) 479 } 480 481 // LastCompletedBuildStatus returns the most recent completed BuildInfo for the given job. 482 // 483 // axisValues can be set to nil if the job is not multi-configuration. 484 func (j *Jenkins) LastCompletedBuildStatus(jobName string, axisValues map[string]string) (*BuildInfo, error) { 485 buildInfo, err := j.BuildInfoForSpec(GenBuildSpec(jobName, axisValues, "lastCompletedBuild")) 486 if err != nil { 487 return nil, err 488 } 489 return buildInfo, nil 490 }