| // Copyright 2015 The Vanadium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package jenkins |
| |
| import ( |
| "encoding/json" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "net/http" |
| "net/url" |
| "regexp" |
| "strconv" |
| "strings" |
| |
| "v.io/jiri/collect" |
| ) |
| |
| func New(host string) (*Jenkins, error) { |
| j := &Jenkins{ |
| host: host, |
| } |
| return j, nil |
| } |
| |
| // NewForTesting creates a Jenkins instance in test mode. |
| func NewForTesting() *Jenkins { |
| return &Jenkins{ |
| testMode: true, |
| invokeMockResults: map[string][]byte{}, |
| } |
| } |
| |
| type Jenkins struct { |
| host string |
| |
| // The following fields are for testing only. |
| |
| // testMode indicates whether this Jenkins instance is in test mode. |
| testMode bool |
| |
| // invokeMockResults maps from API suffix to a mock result. |
| // In test mode, the mock result will be returned when "invoke" is called. |
| invokeMockResults map[string][]byte |
| } |
| |
| // MockAPI mocks "invoke" with the given API suffix. |
| func (j *Jenkins) MockAPI(suffix, result string) { |
| j.invokeMockResults[suffix] = []byte(result) |
| } |
| |
| type QueuedBuild struct { |
| Id int |
| Params string `json:"params,omitempty"` |
| Task QueuedBuildTask |
| } |
| |
| type QueuedBuildTask struct { |
| Name string |
| } |
| |
| // ParseRefs parses refs from a QueuedBuild object's Params field. |
| func (qb *QueuedBuild) ParseRefs() string { |
| // The params string is in the form of: |
| // "\nREFS=ref/changes/12/3412/2\nPROJECTS=test" or |
| // "\nPROJECTS=test\nREFS=ref/changes/12/3412/2" |
| parts := strings.Split(qb.Params, "\n") |
| refs := "" |
| refsPrefix := "REFS=" |
| for _, part := range parts { |
| if strings.HasPrefix(part, refsPrefix) { |
| refs = strings.TrimPrefix(part, refsPrefix) |
| break |
| } |
| } |
| return refs |
| } |
| |
| // QueuedBuilds returns the queued builds. |
| func (j *Jenkins) QueuedBuilds(jobName string) (_ []QueuedBuild, err error) { |
| // Get queued builds. |
| bytes, err := j.invoke("GET", "queue/api/json", url.Values{}) |
| if err != nil { |
| return nil, err |
| } |
| var builds struct { |
| Items []QueuedBuild |
| } |
| if err := json.Unmarshal(bytes, &builds); err != nil { |
| return nil, fmt.Errorf("Unmarshal() failed: %v\n%s", err, string(bytes)) |
| } |
| |
| // Filter for jobName. |
| queuedBuildsForJob := []QueuedBuild{} |
| for _, build := range builds.Items { |
| if build.Task.Name != jobName { |
| continue |
| } |
| queuedBuildsForJob = append(queuedBuildsForJob, build) |
| } |
| return queuedBuildsForJob, nil |
| } |
| |
| type BuildInfo struct { |
| Actions []BuildInfoAction |
| Building bool |
| Number int |
| Result string |
| Id string |
| Timestamp int64 |
| } |
| |
| type BuildInfoAction struct { |
| Parameters []BuildInfoParameter |
| } |
| |
| type BuildInfoParameter struct { |
| Name string |
| Value string |
| } |
| |
| // ParseRefs parses the REFS parameter from a BuildInfo object. |
| func (bi *BuildInfo) ParseRefs() string { |
| refs := "" |
| loop: |
| for _, action := range bi.Actions { |
| for _, param := range action.Parameters { |
| if param.Name == "REFS" { |
| refs = param.Value |
| break loop |
| } |
| } |
| } |
| return refs |
| } |
| |
| // OngoingBuilds returns a slice of BuildInfo for current ongoing builds |
| // for the given job. |
| func (j *Jenkins) OngoingBuilds(jobName string) (_ []BuildInfo, err error) { |
| // Get urls of all ongoing builds. |
| bytes, err := j.invoke("GET", "computer/api/json", url.Values{ |
| "tree": {"computer[executors[currentExecutable[url]],oneOffExecutors[currentExecutable[url]]]"}, |
| }) |
| if err != nil { |
| return nil, err |
| } |
| var computers struct { |
| Computer []struct { |
| Executors []struct { |
| CurrentExecutable struct { |
| Url string |
| } |
| } |
| OneOffExecutors []struct { |
| CurrentExecutable struct { |
| Url string |
| } |
| } |
| } |
| } |
| if err := json.Unmarshal(bytes, &computers); err != nil { |
| return nil, fmt.Errorf("Unmarshal() failed: %v\n%s", err, string(bytes)) |
| } |
| urls := []string{} |
| for _, computer := range computers.Computer { |
| for _, executor := range computer.Executors { |
| curUrl := executor.CurrentExecutable.Url |
| if curUrl != "" { |
| urls = append(urls, curUrl) |
| } |
| } |
| for _, oneOffExecutor := range computer.OneOffExecutors { |
| curUrl := oneOffExecutor.CurrentExecutable.Url |
| if curUrl != "" { |
| urls = append(urls, curUrl) |
| } |
| } |
| } |
| |
| buildInfos := []BuildInfo{} |
| masterJobURLRE := regexp.MustCompile(fmt.Sprintf(`.*/%s/(\d+)/$`, jobName)) |
| for _, curUrl := range urls { |
| // Filter for jobName, and get the build number. |
| matches := masterJobURLRE.FindStringSubmatch(curUrl) |
| if matches == nil { |
| continue |
| } |
| strBuildNumber := matches[1] |
| buildNumber, err := strconv.Atoi(strBuildNumber) |
| if err != nil { |
| return nil, fmt.Errorf("Atoi(%s) failed: %v", strBuildNumber, err) |
| } |
| buildInfo, err := j.BuildInfo(jobName, buildNumber) |
| if err != nil { |
| return nil, err |
| } |
| buildInfos = append(buildInfos, *buildInfo) |
| } |
| return buildInfos, nil |
| } |
| |
| // BuildInfo returns a build's info for the given jobName and buildNumber. |
| func (j *Jenkins) BuildInfo(jobName string, buildNumber int) (*BuildInfo, error) { |
| buildSpec := fmt.Sprintf("%s/%d", jobName, buildNumber) |
| return j.BuildInfoForSpec(buildSpec) |
| } |
| |
| // BuildInfoWithBuildURL returns a build's info for the given build's URL. |
| func (j *Jenkins) BuildInfoForSpec(buildSpec string) (*BuildInfo, error) { |
| getBuildInfoUri := fmt.Sprintf("job/%s/api/json", buildSpec) |
| bytes, err := j.invoke("GET", getBuildInfoUri, url.Values{}) |
| if err != nil { |
| return nil, err |
| } |
| var buildInfo BuildInfo |
| if err := json.Unmarshal(bytes, &buildInfo); err != nil { |
| return nil, fmt.Errorf("Unmarshal() failed: %v\n%s", err, string(bytes)) |
| } |
| return &buildInfo, nil |
| } |
| |
| // AddBuild adds a build to the given job. |
| func (j *Jenkins) AddBuild(jobName string) error { |
| addBuildUri := fmt.Sprintf("job/%s/build", jobName) |
| _, err := j.invoke("POST", addBuildUri, url.Values{}) |
| if err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // AddBuildWithParameter adds a parameterized build to the given job. |
| func (j *Jenkins) AddBuildWithParameter(jobName string, params url.Values) error { |
| addBuildUri := fmt.Sprintf("job/%s/buildWithParameters", jobName) |
| _, err := j.invoke("POST", addBuildUri, params) |
| if err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // CancelQueuedBuild cancels the queued build by given id. |
| func (j *Jenkins) CancelQueuedBuild(id string) error { |
| cancelQueuedBuildUri := "queue/cancelItem" |
| if _, err := j.invoke("POST", cancelQueuedBuildUri, url.Values{ |
| "id": {id}, |
| }); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // CancelOngoingBuild cancels the ongoing build by given jobName and buildNumber. |
| func (j *Jenkins) CancelOngoingBuild(jobName string, buildNumber int) error { |
| cancelOngoingBuildUri := fmt.Sprintf("job/%s/%d/stop", jobName, buildNumber) |
| if _, err := j.invoke("POST", cancelOngoingBuildUri, url.Values{}); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| type TestCase struct { |
| ClassName string |
| Name string |
| Status string |
| } |
| |
| func (t TestCase) Equal(t2 TestCase) bool { |
| return t.ClassName == t2.ClassName && t.Name == t2.Name |
| } |
| |
| // FailedTestCasesForBuildSpec returns failed test cases for the given build spec. |
| func (j *Jenkins) FailedTestCasesForBuildSpec(buildSpec string) ([]TestCase, error) { |
| failedTestCases := []TestCase{} |
| |
| // Get all test cases. |
| getTestReportUri := fmt.Sprintf("job/%s/testReport/api/json", buildSpec) |
| bytes, err := j.invoke("GET", getTestReportUri, url.Values{}) |
| if err != nil { |
| return failedTestCases, err |
| } |
| var testCases struct { |
| Suites []struct { |
| Cases []TestCase |
| } |
| } |
| if err := json.Unmarshal(bytes, &testCases); err != nil { |
| return failedTestCases, fmt.Errorf("Unmarshal(%v) failed: %v", string(bytes), err) |
| } |
| |
| // Filter failed tests. |
| for _, suite := range testCases.Suites { |
| for _, curCase := range suite.Cases { |
| if curCase.Status == "FAILED" || curCase.Status == "REGRESSION" { |
| failedTestCases = append(failedTestCases, curCase) |
| } |
| } |
| } |
| return failedTestCases, nil |
| } |
| |
| // JenkinsMachines stores information about Jenkins machines. |
| type JenkinsMachines struct { |
| Machines []JenkinsMachine `json:"computer"` |
| } |
| |
| // JenkinsMachine stores information about a Jenkins machine. |
| type JenkinsMachine struct { |
| Name string `json:"displayName"` |
| Idle bool `json:"idle"` |
| } |
| |
| // IsNodeIdle checks whether the given node is idle. |
| func (j *Jenkins) IsNodeIdle(node string) (bool, error) { |
| bytes, err := j.invoke("GET", "computer/api/json", url.Values{}) |
| if err != nil { |
| return false, err |
| } |
| machines := JenkinsMachines{} |
| if err := json.Unmarshal(bytes, &machines); err != nil { |
| return false, fmt.Errorf("Unmarshal() failed: %v\n%s\n", err, string(bytes)) |
| } |
| for _, machine := range machines.Machines { |
| if machine.Name == node { |
| return machine.Idle, nil |
| } |
| } |
| return false, fmt.Errorf("node %v not found", node) |
| } |
| |
| // createRequest represents a request to create a new machine in |
| // Jenkins configuration. |
| type createRequest struct { |
| Name string `json:"name"` |
| Description string `json:"nodeDescription"` |
| NumExecutors int `json:"numExecutors"` |
| RemoteFS string `json:"remoteFS"` |
| Labels string `json:"labelString"` |
| Mode string `json:"mode"` |
| Type string `json:"type"` |
| RetentionStrategy map[string]string `json:"retentionStrategy"` |
| NodeProperties nodeProperties `json:"nodeProperties"` |
| Launcher map[string]string `json:"launcher"` |
| } |
| |
| // nodeProperties enumerates the environment variable settings for |
| // Jenkins configuration. |
| type nodeProperties struct { |
| Class string `json:"stapler-class"` |
| Environment []map[string]string `json:"env"` |
| } |
| |
| // AddNodeToJenkins sends an HTTP request to Jenkins that prompts it |
| // to add a new machine to its configuration. |
| // |
| // NOTE: Jenkins REST API is not documented anywhere and the |
| // particular HTTP request used to add a new machine to Jenkins |
| // configuration has been crafted using trial and error. |
| func (j *Jenkins) AddNodeToJenkins(name, host, description, credentialsId string) error { |
| request := createRequest{ |
| Name: name, |
| Description: description, |
| NumExecutors: 1, |
| RemoteFS: "/home/veyron/jenkins", |
| Labels: fmt.Sprintf("%s linux", name), |
| Mode: "EXCLUSIVE", |
| Type: "hudson.slaves.DumbSlave$DescriptorImpl", |
| RetentionStrategy: map[string]string{"stapler-class": "hudson.slaves.RetentionStrategy$Always"}, |
| NodeProperties: nodeProperties{ |
| Class: "hudson.slaves.EnvironmentVariablesNodeProperty", |
| Environment: []map[string]string{ |
| map[string]string{ |
| "stapler-class": "hudson.slaves.EnvironmentVariablesNodeProperty$Entry", |
| "key": "GOROOT", |
| "value": "$HOME/go", |
| }, |
| map[string]string{ |
| "stapler-class": "hudson.slaves.EnvironmentVariablesNodeProperty$Entry", |
| "key": "PATH", |
| "value": "$HOME/go/bin:$PATH", |
| }, |
| map[string]string{ |
| "stapler-class": "hudson.slaves.EnvironmentVariablesNodeProperty$Entry", |
| "key": "TERM", |
| "value": "xterm-256color", |
| }, |
| }, |
| }, |
| Launcher: map[string]string{ |
| "stapler-class": "hudson.plugins.sshslaves.SSHLauncher", |
| "host": host, |
| // The following ID can be retrieved from Jenkins configuration backup. |
| "credentialsId": credentialsId, |
| }, |
| } |
| bytes, err := json.Marshal(request) |
| if err != nil { |
| return fmt.Errorf("Marshal(%v) failed: %v", request, err) |
| } |
| values := url.Values{ |
| "name": {name}, |
| "type": {"hudson.slaves.DumbSlave$DescriptorImpl"}, |
| "json": {string(bytes)}, |
| } |
| _, err = j.invoke("GET", "computer/doCreateItem", values) |
| if err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // RemoveNodeFromJenkins sends an HTTP request to Jenkins that prompts |
| // it to remove an existing machine from its configuration. |
| func (j *Jenkins) RemoveNodeFromJenkins(node string) error { |
| _, err := j.invoke("POST", fmt.Sprintf("computer/%s/doDelete", node), url.Values{}) |
| if err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // invoke invokes the Jenkins API using the given suffix, values and |
| // HTTP method. |
| func (j *Jenkins) invoke(method, suffix string, values url.Values) (_ []byte, err error) { |
| // Return mock result in test mode. |
| if j.testMode { |
| return j.invokeMockResults[suffix], nil |
| } |
| |
| apiURL, err := url.Parse(j.host) |
| if err != nil { |
| return nil, fmt.Errorf("Parse(%q) failed: %v", j.host, err) |
| } |
| apiURL.Path = fmt.Sprintf("%s/%s", apiURL.Path, suffix) |
| apiURL.RawQuery = values.Encode() |
| var body io.Reader |
| url, body := apiURL.String(), nil |
| req, err := http.NewRequest(method, url, body) |
| if err != nil { |
| return nil, fmt.Errorf("NewRequest(%q, %q, %v) failed: %v", method, url, body, err) |
| } |
| req.Header.Add("Accept", "application/json") |
| res, err := http.DefaultClient.Do(req) |
| if err != nil { |
| return nil, fmt.Errorf("Do(%v) failed: %v", req, err) |
| } |
| defer collect.Error(func() error { return res.Body.Close() }, &err) |
| bytes, err := ioutil.ReadAll(res.Body) |
| if err != nil { |
| return nil, err |
| } |
| // queue/cancelItem API returns 404 even successful. |
| // See: https://issues.jenkins-ci.org/browse/JENKINS-21311. |
| if suffix != "queue/cancelItem" && res.StatusCode >= http.StatusBadRequest { |
| return nil, fmt.Errorf("HTTP request %q returned %d:\n%s", url, res.StatusCode, string(bytes)) |
| } |
| return bytes, nil |
| } |
| |
| // GenBuildSpec returns a spec string for the given Jenkins build. |
| // |
| // If the main job is a multi-configuration job, the spec is in the form of: |
| // <jobName>/axis1Label=axis1Value,axis2Label=axis2Value,.../<suffix> |
| // The axis values are taken from the given axisValues map. |
| // |
| // If no axisValues are provides, the spec will be: <jobName>/<suffix>. |
| func GenBuildSpec(jobName string, axisValues map[string]string, suffix string) string { |
| if len(axisValues) == 0 { |
| return fmt.Sprintf("%s/%s", jobName, suffix) |
| } |
| |
| parts := []string{} |
| for k, v := range axisValues { |
| parts = append(parts, fmt.Sprintf("%s=%s", k, v)) |
| } |
| return fmt.Sprintf("%s/%s/%s", jobName, strings.Join(parts, ","), suffix) |
| } |
| |
| // LastCompletedBuildStatus returns the most recent completed BuildInfo for the given job. |
| // |
| // axisValues can be set to nil if the job is not multi-configuration. |
| func (j *Jenkins) LastCompletedBuildStatus(jobName string, axisValues map[string]string) (*BuildInfo, error) { |
| buildInfo, err := j.BuildInfoForSpec(GenBuildSpec(jobName, axisValues, "lastCompletedBuild")) |
| if err != nil { |
| return nil, err |
| } |
| return buildInfo, nil |
| } |