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  }