improved_jenkins_client 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,855 @@
1
+ #
2
+ # Copyright (c) 2012-2013 Kannan Manickam <arangamani.kannan@gmail.com>
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ # of this software and associated documentation files (the "Software"), to deal
6
+ # in the Software without restriction, including without limitation the rights
7
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the Software is
9
+ # furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in
12
+ # all copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ # THE SOFTWARE.
21
+ #
22
+
23
+ require 'rubygems'
24
+ require 'json'
25
+ require 'net/http'
26
+ require 'net/https'
27
+ require 'nokogiri'
28
+ require 'base64'
29
+ require 'mixlib/shellout'
30
+ require 'uri'
31
+ require 'logger'
32
+ require 'socksify/http'
33
+ require 'open-uri'
34
+
35
+ # The main module that contains the Client class and all subclasses that
36
+ # communicate with the Jenkins's Remote Access API.
37
+ #
38
+ module JenkinsApi
39
+ # This is the client class that acts as the bridge between the subclasses and
40
+ # Jenkins. This class contains methods that performs GET and POST requests
41
+ # for various operations.
42
+ #
43
+ class Client
44
+ attr_accessor :timeout, :logger
45
+ # Default port to be used to connect to Jenkins
46
+ DEFAULT_SERVER_PORT = 8080
47
+ # Default timeout in seconds to be used while performing operations
48
+ DEFAULT_TIMEOUT = 120
49
+ DEFAULT_HTTP_OPEN_TIMEOUT = 10
50
+ DEFAULT_HTTP_READ_TIMEOUT = 120
51
+ # Parameters that are permitted as options while initializing the client
52
+ VALID_PARAMS = [
53
+ "server_url",
54
+ "server_ip",
55
+ "server_port",
56
+ "proxy_ip",
57
+ "proxy_port",
58
+ "proxy_protocol",
59
+ "jenkins_path",
60
+ "username",
61
+ "password",
62
+ "password_base64",
63
+ "logger",
64
+ "log_location",
65
+ "log_level",
66
+ "timeout",
67
+ "http_open_timeout",
68
+ "http_read_timeout",
69
+ "ssl",
70
+ "pkcs_file_path",
71
+ "pass_phrase",
72
+ "ca_file",
73
+ "follow_redirects",
74
+ "identity_file",
75
+ "cookies"
76
+ ].freeze
77
+
78
+ # Initialize a Client object with Jenkins CI server credentials
79
+ #
80
+ # @param args [Hash] Arguments to connect to Jenkins server
81
+ #
82
+ # @option args [String] :server_ip the IP address of the Jenkins CI server
83
+ # @option args [String] :server_port the port on which the Jenkins listens
84
+ # @option args [String] :server_url the full URL address of the Jenkins CI server (http/https). This can include
85
+ # username/password. :username/:password options will override any user/pass value in the URL
86
+ # @option args [String] :username the username used for connecting to the server (optional)
87
+ # @option args [String] :password the password or API Key for connecting to the CI server (optional)
88
+ # @option args [String] :password_base64 the password with base64 encoded format for connecting to the CI
89
+ # server (optional)
90
+ # @option args [String] :identity_file the priviate key file for Jenkins CLI authentication,
91
+ # it is used only for executing CLI commands. Also remember to upload the public key to
92
+ # <Server IP>:<Server Port>/user/<Username>/configure
93
+ # @option args [String] :proxy_ip the proxy IP address
94
+ # @option args [String] :proxy_port the proxy port
95
+ # @option args [String] :proxy_protocol the proxy protocol ('socks' or 'http' (defaults to HTTP)
96
+ # @option args [String] :jenkins_path ("/") the optional context path for Jenkins
97
+ # @option args [Boolean] :ssl (false) indicates if Jenkins is accessible over HTTPS
98
+ # @option args [String] :pkcs_file_path ("/") the optional context path for pfx or p12 binary certificate file
99
+ # @option args [String] :pass_phrase password for pkcs_file_path certificate file
100
+ # @option args [String] :ca_file the path to a PEM encoded file containing trusted certificates used to verify peer certificate
101
+ # @option args [Boolean] :follow_redirects this argument causes the client to follow a redirect (jenkins can
102
+ # return a 30x when starting a build)
103
+ # @option args [Fixnum] :timeout (120) This argument sets the timeout for operations that take longer (in seconds)
104
+ # @option args [Logger] :logger a Logger object, used to override the default logger (optional)
105
+ # @option args [String] :log_location (STDOUT) the location for the log file
106
+ # @option args [Fixnum] :log_level (Logger::INFO) The level for messages to be logged. Should be one of:
107
+ # Logger::DEBUG (0), Logger::INFO (1), Logger::WARN (2), Logger::ERROR (2), Logger::FATAL (3)
108
+ # @option args [String] :cookies Cookies to be sent with all requests in the format: name=value; name2=value2
109
+ #
110
+ # @return [JenkinsApi::Client] a client object to Jenkins API
111
+ #
112
+ # @raise [ArgumentError] when required options are not provided.
113
+ #
114
+ def initialize(args)
115
+ args = symbolize_keys(args)
116
+ args.each do |key, value|
117
+ if value && VALID_PARAMS.include?(key.to_s)
118
+ instance_variable_set("@#{key}", value)
119
+ end
120
+ end if args.is_a? Hash
121
+
122
+ # Server IP or Server URL must be specific
123
+ unless @server_ip || @server_url
124
+ raise ArgumentError, "Server IP or Server URL is required to connect" +
125
+ " to Jenkins"
126
+ end
127
+
128
+ # Get info from the server_url, if we got one
129
+ if @server_url
130
+ server_uri = URI.parse(@server_url)
131
+ @server_ip = server_uri.host
132
+ @server_port = server_uri.port
133
+ @ssl = server_uri.scheme == "https"
134
+ @jenkins_path = server_uri.path
135
+
136
+ # read username and password from the URL
137
+ # only set if @username and @password are not already set via explicit options
138
+ @username ||= server_uri.user
139
+ @password ||= server_uri.password
140
+ end
141
+
142
+ # Username/password are optional as some jenkins servers do not require
143
+ # authentication
144
+ if @username && !(@password || @password_base64)
145
+ raise ArgumentError, "If username is provided, password is required"
146
+ end
147
+ if @proxy_ip.nil? ^ @proxy_port.nil?
148
+ raise ArgumentError, "Proxy IP and port must both be specified or" +
149
+ " both left nil"
150
+ end
151
+
152
+ @jenkins_path ||= ""
153
+ @jenkins_path.gsub!(/\/$/,"") # remove trailing slash if there is one
154
+ @server_port = DEFAULT_SERVER_PORT unless @server_port
155
+ @timeout = DEFAULT_TIMEOUT unless @timeout
156
+ @http_open_timeout = DEFAULT_HTTP_OPEN_TIMEOUT unless @http_open_timeout
157
+ @http_read_timeout = DEFAULT_HTTP_READ_TIMEOUT unless @http_read_timeout
158
+ @ssl ||= false
159
+ @proxy_protocol ||= 'http'
160
+
161
+ # Setting log options
162
+ if @logger
163
+ raise ArgumentError, "logger parameter must be a Logger object" unless @logger.is_a?(Logger)
164
+ raise ArgumentError, "log_level should not be set if using custom logger" if @log_level
165
+ raise ArgumentError, "log_location should not be set if using custom logger" if @log_location
166
+ else
167
+ @log_location = STDOUT unless @log_location
168
+ @log_level = Logger::INFO unless @log_level
169
+ @logger = Logger.new(@log_location)
170
+ @logger.level = @log_level
171
+ end
172
+
173
+ # Base64 decode inserts a newline character at the end. As a workaround
174
+ # added chomp to remove newline characters. I hope nobody uses newline
175
+ # characters at the end of their passwords :)
176
+ @password = Base64.decode64(@password_base64).chomp if @password_base64
177
+
178
+ # No connections are made to the Jenkins server during initialize to
179
+ # allow the unit tests to behave normally as mocking is simpler this way.
180
+ # If this variable is nil, the first POST request will query the API and
181
+ # populate this variable.
182
+ @crumbs_enabled = nil
183
+ # The crumbs hash. Store it so that we don't have to obtain the crumb for
184
+ # every POST request. It appears that the crumb doesn't change often.
185
+ @crumb = {}
186
+ # This is the number of times to refetch the crumb if it ever expires.
187
+ @crumb_max_retries = 3
188
+ end
189
+
190
+ # Creates an instance to the Job class by passing a reference to self
191
+ #
192
+ # @return [JenkinsApi::Client::Job] An object to Job subclass
193
+ #
194
+ def job
195
+ JenkinsApi::Client::Job.new(self)
196
+ end
197
+
198
+ # Creates an instance to the System class by passing a reference to self
199
+ #
200
+ # @return [JenkinsApi::Client::System] An object to System subclass
201
+ #
202
+ def system
203
+ JenkinsApi::Client::System.new(self)
204
+ end
205
+
206
+ # Creates an instance to the Node class by passing a reference to self
207
+ #
208
+ # @return [JenkinsApi::Client::Node] An object to Node subclass
209
+ #
210
+ def node
211
+ JenkinsApi::Client::Node.new(self)
212
+ end
213
+
214
+ # Creates an instance to the View class by passing a reference to self
215
+ #
216
+ # @return [JenkinsApi::Client::View] An object to View subclass
217
+ #
218
+ def view
219
+ JenkinsApi::Client::View.new(self)
220
+ end
221
+
222
+ # Creates an instance to the BuildQueue by passing a reference to self
223
+ #
224
+ # @return [JenkinsApi::Client::BuildQueue] An object to BuildQueue subclass
225
+ #
226
+ def queue
227
+ JenkinsApi::Client::BuildQueue.new(self)
228
+ end
229
+
230
+ # Creates an instance to the PluginManager by passing a reference to self
231
+ #
232
+ # @return [JenkinsApi::Client::PluginManager] an object to PluginManager
233
+ # subclass
234
+ #
235
+ def plugin
236
+ JenkinsApi::Client::PluginManager.new(self)
237
+ end
238
+
239
+ # Creates an instance of the User class by passing a reference to self
240
+ #
241
+ # @return [JenkinsApi::Client::User] An object of User subclass
242
+ #
243
+ def user
244
+ JenkinsApi::Client::User.new(self)
245
+ end
246
+
247
+ # Creates an instance of the Root class by passing a reference to self
248
+ #
249
+ # @return [JenkinsApi::Client::Root] An object of Root subclass
250
+ #
251
+ def root
252
+ JenkinsApi::Client::Root.new(self)
253
+ end
254
+
255
+ # Returns a string representing the class name
256
+ #
257
+ # @return [String] string representation of class name
258
+ #
259
+ def to_s
260
+ "#<JenkinsApi::Client>"
261
+ end
262
+
263
+ # Overrides the inspect method to get rid of the credentials being shown in
264
+ # the in interactive IRB sessions and also when the `inspect` method is
265
+ # called. Just print the important variables.
266
+ #
267
+ def inspect
268
+ "#<JenkinsApi::Client:0x#{(self.__id__ * 2).to_s(16)}" +
269
+ " @ssl=#{@ssl.inspect}," +
270
+ " @ca_file=#{@ca_file.inspect}," +
271
+ " @log_location=#{@log_location.inspect}," +
272
+ " @log_level=#{@log_level.inspect}," +
273
+ " @crumbs_enabled=#{@crumbs_enabled.inspect}," +
274
+ " @follow_redirects=#{@follow_redirects.inspect}," +
275
+ " @jenkins_path=#{@jenkins_path.inspect}," +
276
+ " @timeout=#{@timeout.inspect}>," +
277
+ " @http_open_timeout=#{@http_open_timeout.inspect}>," +
278
+ " @http_read_timeout=#{@http_read_timeout.inspect}>"
279
+ end
280
+
281
+ # Connects to the server and downloads artifacts to a specified location
282
+ #
283
+ # @param [String] job_name
284
+ # @param [String] filename location to save artifact
285
+ #
286
+ def get_artifact(job_name,filename)
287
+ @artifact = job.find_artifact(job_name)
288
+ response = make_http_request(Net::HTTP::Get.new(@artifact))
289
+ if response.code == "200"
290
+ File.write(File.expand_path(filename), response.body)
291
+ else
292
+ raise "Couldn't get the artifact"
293
+ end
294
+ end
295
+
296
+ # Connects to the server and download all artifacts of a build to a specified location
297
+ #
298
+ # @param [String] job_name
299
+ # @param [String] dldir location to save artifacts
300
+ # @param [Integer] build_number optional, defaults to current build
301
+ # @returns [String, Array] list of retrieved artifacts
302
+ #
303
+ def get_artifacts(job_name, dldir, build_number = nil)
304
+ @artifacts = job.find_artifacts(job_name,build_number)
305
+ results = []
306
+ @artifacts.each do |artifact|
307
+ uri = URI.parse(artifact)
308
+ http = Net::HTTP.new(uri.host, uri.port)
309
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
310
+ http.use_ssl = @ssl
311
+ request = Net::HTTP::Get.new(uri.request_uri)
312
+ request.basic_auth(@username, @password)
313
+ response = http.request(request)
314
+ # we want every thing after the last 'build' in the path to become the filename
315
+ if artifact.include?('/build/')
316
+ filename = artifact.split("/build/").last.gsub('/','-')
317
+ else
318
+ filename = File.basename(artifact)
319
+ end
320
+ filename = File.join(dldir, filename)
321
+ results << filename
322
+ if response.code == "200"
323
+ File.write(File.expand_path(filename), response.body)
324
+ else
325
+ raise "Couldn't get the artifact #{artifact} for job #{job}"
326
+ end
327
+ end
328
+ results
329
+ end
330
+
331
+ # Connects to the Jenkins server, sends the specified request and returns
332
+ # the response.
333
+ #
334
+ # @param [Net::HTTPRequest] request The request object to send
335
+ # @param [Boolean] follow_redirect whether to follow redirects or not
336
+ #
337
+ # @return [Net::HTTPResponse] Response from Jenkins
338
+ #
339
+ def make_http_request(request, follow_redirect = @follow_redirects)
340
+ request.basic_auth @username, @password if @username
341
+ request['Cookie'] = @cookies if @cookies
342
+
343
+ if @proxy_ip
344
+ case @proxy_protocol
345
+ when 'http'
346
+ http = Net::HTTP::Proxy(@proxy_ip, @proxy_port).new(@server_ip, @server_port)
347
+ when 'socks'
348
+ http = Net::HTTP::SOCKSProxy(@proxy_ip, @proxy_port).start(@server_ip, @server_port)
349
+ else
350
+ raise "unknown proxy protocol: '#{@proxy_protocol}'"
351
+ end
352
+ else
353
+ http = Net::HTTP.new(@server_ip, @server_port)
354
+ end
355
+
356
+ if @ssl && @pkcs_file_path
357
+ http.use_ssl = true
358
+ pkcs12 =OpenSSL::PKCS12.new(File.binread(@pkcs_file_path), @pass_phrase!=nil ? @pass_phrase : "")
359
+ http.cert = pkcs12.certificate
360
+ http.key = pkcs12.key
361
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
362
+ elsif @ssl
363
+ http.use_ssl = true
364
+
365
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
366
+ http.ca_file = @ca_file if @ca_file
367
+ end
368
+ http.open_timeout = @http_open_timeout
369
+ http.read_timeout = @http_read_timeout
370
+
371
+ response = http.request(request)
372
+ case response
373
+ when Net::HTTPRedirection then
374
+ # If we got a redirect request, follow it (if flag set), but don't
375
+ # go any deeper (only one redirect supported - don't want to follow
376
+ # our tail)
377
+ if follow_redirect
378
+ redir_uri = URI.parse(response['location'])
379
+ response = make_http_request(
380
+ Net::HTTP::Get.new(redir_uri.path, false)
381
+ )
382
+ end
383
+ end
384
+
385
+ # Pick out some useful header info before we return
386
+ @jenkins_version = response['X-Jenkins']
387
+ @hudson_version = response['X-Hudson']
388
+
389
+ return response
390
+ end
391
+ protected :make_http_request
392
+
393
+ # Obtains the root of Jenkins server. This function is used to see if
394
+ # Jenkins is running
395
+ #
396
+ # @return [Net::HTTP::Response] Response from Jenkins for "/"
397
+ #
398
+ def get_root
399
+ @logger.debug "GET #{@jenkins_path}/"
400
+ request = Net::HTTP::Get.new("#{@jenkins_path}/")
401
+ make_http_request(request)
402
+ end
403
+
404
+ # Sends a GET request to the Jenkins CI server with the specified URL
405
+ #
406
+ # @param [String] url_prefix The prefix to use in the URL
407
+ # @param [String] tree A specific JSON tree to optimize the API call
408
+ # @param [String] url_suffix The suffix to be used in the URL
409
+ # @param [Boolean] raw_response Return complete Response object instead of
410
+ # JSON body of response
411
+ #
412
+ # @return [String, Hash] JSON response from Jenkins
413
+ #
414
+ def api_get_request(url_prefix, tree = nil, url_suffix ="/api/json",
415
+ raw_response = false)
416
+ url_prefix = "#{@jenkins_path}#{url_prefix}"
417
+ to_get = ""
418
+ if tree
419
+ to_get = "#{url_prefix}#{url_suffix}?#{tree}"
420
+ else
421
+ to_get = "#{url_prefix}#{url_suffix}"
422
+ end
423
+ request = Net::HTTP::Get.new(to_get)
424
+ @logger.debug "GET #{to_get}"
425
+ response = make_http_request(request)
426
+ if raw_response
427
+ handle_exception(response, "raw")
428
+ else
429
+ handle_exception(response, "body", url_suffix =~ /json/)
430
+ end
431
+ end
432
+
433
+ # Sends a POST message to the Jenkins CI server with the specified URL
434
+ #
435
+ # @param [String] url_prefix The prefix to be used in the URL
436
+ # @param [Hash] form_data Form data to send with POST request
437
+ # @param [Boolean] raw_response Return complete Response object instead of
438
+ # JSON body of response
439
+ #
440
+ # @return [String] Response code form Jenkins Response
441
+ #
442
+ def api_post_request(url_prefix, form_data = {}, raw_response = false)
443
+ retries = @crumb_max_retries
444
+ begin
445
+ refresh_crumbs
446
+
447
+ # Added form_data default {} instead of nil to help with proxies
448
+ # that barf with empty post
449
+ request = Net::HTTP::Post.new("#{@jenkins_path}#{url_prefix}")
450
+ @logger.debug "POST #{url_prefix}"
451
+ if @crumbs_enabled
452
+ request[@crumb["crumbRequestField"]] = @crumb["crumb"]
453
+ end
454
+ request.set_form_data(form_data)
455
+ response = make_http_request(request)
456
+ if raw_response
457
+ handle_exception(response, "raw")
458
+ else
459
+ handle_exception(response)
460
+ end
461
+ rescue Exceptions::ForbiddenException => e
462
+ refresh_crumbs(true)
463
+
464
+ if @crumbs_enabled
465
+ @logger.info "Retrying: #{@crumb_max_retries - retries + 1} out of" +
466
+ " #{@crumb_max_retries} times..."
467
+ retries -= 1
468
+
469
+ if retries > 0
470
+ retry
471
+ else
472
+ raise Exceptions::ForbiddenWithCrumb.new(@logger, e.message)
473
+ end
474
+ else
475
+ raise
476
+ end
477
+ end
478
+ end
479
+
480
+ # Obtains the configuration of a component from the Jenkins CI server
481
+ #
482
+ # @param [String] url_prefix The prefix to be used in the URL
483
+ #
484
+ # @return [String] XML configuration obtained from Jenkins
485
+ #
486
+ def get_config(url_prefix)
487
+ request = Net::HTTP::Get.new("#{@jenkins_path}#{url_prefix}/config.xml")
488
+ @logger.debug "GET #{url_prefix}/config.xml"
489
+ response = make_http_request(request)
490
+ handle_exception(response, "body")
491
+ end
492
+
493
+ # Posts the given xml configuration to the url given
494
+ #
495
+ # @param [String] url_prefix The prefix to be used in the URL
496
+ # @param [String] xml The XML configuration to be sent to Jenkins
497
+ #
498
+ # @return [String] Response code returned from Jenkins
499
+ #
500
+ def post_config(url_prefix, xml)
501
+ post_data(url_prefix, xml, 'application/xml;charset=UTF-8')
502
+ end
503
+
504
+ def post_json(url_prefix, json)
505
+ post_data(url_prefix, json, 'application/json;charset=UTF-8')
506
+ end
507
+
508
+ def post_data(url_prefix, data, content_type)
509
+ retries = @crumb_max_retries
510
+ begin
511
+ refresh_crumbs
512
+
513
+ request = Net::HTTP::Post.new("#{@jenkins_path}#{url_prefix}")
514
+ @logger.debug "POST #{url_prefix}"
515
+ request.body = data
516
+ request.content_type = content_type
517
+ if @crumbs_enabled
518
+ request[@crumb["crumbRequestField"]] = @crumb["crumb"]
519
+ end
520
+ response = make_http_request(request)
521
+ handle_exception(response)
522
+ rescue Exceptions::ForbiddenException => e
523
+ refresh_crumbs(true)
524
+
525
+ if @crumbs_enabled
526
+ @logger.info "Retrying: #{@crumb_max_retries - retries + 1} out of" +
527
+ " #{@crumb_max_retries} times..."
528
+ retries -= 1
529
+
530
+ if retries > 0
531
+ retry
532
+ else
533
+ raise Exceptions::ForbiddenWithCrumb.new(@logger, e.message)
534
+ end
535
+ else
536
+ raise
537
+ end
538
+ end
539
+ end
540
+
541
+ def init_update_center
542
+ @logger.info "Initializing Jenkins Update Center..."
543
+ @logger.debug "Obtaining the JSON data for Update Center..."
544
+ # TODO: Clean me up
545
+ update_center_data = open("https://updates.jenkins.io/current/update-center.json").read
546
+ # The Jenkins mirror returns the data in the following format
547
+ # updateCenter.post(
548
+ # {.. JSON data...}
549
+ # );
550
+ # which is used by the Javascript used by the Jenkins UI to send to Jenkins.
551
+ #
552
+ update_center_data.gsub!("updateCenter.post(\n", "")
553
+ update_center_data.gsub!("\n);", "")
554
+
555
+ @logger.debug "Posting the obtained JSON to Jenkins Update Center..."
556
+ post_json("/updateCenter/byId/default/postBack", update_center_data)
557
+ end
558
+
559
+ # Checks if Jenkins uses crumbs (i.e) the XSS disable option is checked in
560
+ # Jenkins' security settings
561
+ #
562
+ # @return [Boolean] whether Jenkins uses crumbs or not
563
+ #
564
+ def use_crumbs?
565
+ response = api_get_request("", "tree=useCrumbs")
566
+ response["useCrumbs"]
567
+ end
568
+
569
+ # Checks if Jenkins uses security
570
+ #
571
+ # @return [Boolean] whether Jenkins uses security or not
572
+ #
573
+ def use_security?
574
+ response = api_get_request("", "tree=useSecurity")
575
+ response["useSecurity"]
576
+ end
577
+
578
+ # Obtains the jenkins version from the API
579
+ # Only queries Jenkins if the version is not already stored.
580
+ # Note that the version is auto-updated after every request made to Jenkins
581
+ # since it is returned as a header in every response
582
+ #
583
+ # @return [String] Jenkins version
584
+ #
585
+ def get_jenkins_version
586
+ get_root if @jenkins_version.nil?
587
+ @jenkins_version
588
+ end
589
+
590
+ # Obtain the Hudson version of the CI server
591
+ # Only queries Hudson/Jenkins if the version is not already stored.
592
+ # Note that the version is auto-updated after every request made to Jenkins
593
+ # since it is returned as a header in every response
594
+ #
595
+ # @return [String] Version of Hudson on Jenkins server
596
+ #
597
+ def get_hudson_version
598
+ get_root if @hudson_version.nil?
599
+ @hudson_version
600
+ end
601
+
602
+ # Converts a version string to a list of integers
603
+ # This makes it easier to compare versions since in 'version-speak',
604
+ # v 1.2 is a lot older than v 1.102 - and simple < > on version
605
+ # strings doesn't work so well
606
+ def deconstruct_version_string(version)
607
+ match = version.match(/^(\d+)\.(\d+)(?:\.(\d+))?$/)
608
+
609
+ # Match should have 4 parts [0] = input string, [1] = major
610
+ # [2] = minor, [3] = patch (possibly blank)
611
+ if match && match.size == 4
612
+ return [match[1].to_i, match[2].to_i, match[3].to_i || 0]
613
+ else
614
+ return nil
615
+ end
616
+ end
617
+
618
+ # Compare two version strings (A and B)
619
+ # if A == B, returns 0
620
+ # if A > B, returns 1
621
+ # if A < B, returns -1
622
+ def compare_versions(version_a, version_b)
623
+ if version_a == version_b
624
+ return 0
625
+ else
626
+ version_a_d = deconstruct_version_string(version_a)
627
+ version_b_d = deconstruct_version_string(version_b)
628
+
629
+ if version_a_d[0] > version_b_d[0] ||
630
+ (version_a_d[0] == version_b_d[0] && version_a_d[1] > version_b_d[1]) ||
631
+ (version_a_d[0] == version_b_d[0] && version_a_d[1] == version_b_d[1] && version_a_d[2] > version_b_d[2])
632
+ return 1
633
+ else
634
+ return -1
635
+ end
636
+ end
637
+ end
638
+
639
+ # Obtain the date of the Jenkins server
640
+ #
641
+ # @return [String] Server date
642
+ #
643
+ def get_server_date
644
+ response = get_root
645
+ response["Date"]
646
+ end
647
+
648
+ # Executes the provided groovy script on the Jenkins CI server
649
+ #
650
+ # @param [String] script_text The text of the groovy script to execute
651
+ #
652
+ # @return [String] The output of the executed groovy script
653
+ #
654
+ def exec_script(script_text)
655
+ response = api_post_request('/scriptText', {'script' => script_text}, true)
656
+ response.body
657
+ end
658
+
659
+ # Execute the Jenkins CLI
660
+ #
661
+ # @param command [String] command name
662
+ # @param args [Array] the arguments for the command
663
+ #
664
+ # @return [String] command output from the CLI
665
+ #
666
+ # @raise [Exceptions::CLIException] if there are issues in running the
667
+ # commands using CLI
668
+ #
669
+ def exec_cli(command, args = [])
670
+ base_dir = File.dirname(__FILE__)
671
+ server_url = "http://#{@server_ip}:#{@server_port}/#{@jenkins_path}"
672
+ cmd = "java -jar #{base_dir}/../../java_deps/jenkins-cli.jar -s #{server_url}"
673
+ cmd << " -i #{@identity_file}" if @identity_file && !@identity_file.empty?
674
+ cmd << " #{command}"
675
+ cmd << " --username #{@username} --password #{@password}" if @identity_file.nil? || @identity_file.empty?
676
+ cmd << ' '
677
+ cmd << args.join(' ')
678
+ java_cmd = Mixlib::ShellOut.new(cmd)
679
+
680
+ # Run the command
681
+ java_cmd.run_command
682
+ if java_cmd.stderr.empty?
683
+ java_cmd.stdout.chomp
684
+ else
685
+ # The stderr has a stack trace of the Java program. We'll already have
686
+ # a stack trace for Ruby. So just display a descriptive message for the
687
+ # error thrown by the CLI.
688
+ raise Exceptions::CLIException.new(
689
+ @logger,
690
+ java_cmd.stderr.split("\n").first
691
+ )
692
+ end
693
+ end
694
+
695
+ private
696
+
697
+ # Obtains the crumb from Jenkins' crumb issuer
698
+ #
699
+ # @return [Hash<String, String>] the crumb response from Jenkins' crumb
700
+ # issuer
701
+ #
702
+ # @raise Exceptions::CrumbNotFoundException if the crumb is not provided
703
+ # (i.e) XSS disable option is not checked in Jenkins' security setting
704
+ #
705
+ def get_crumb
706
+ begin
707
+ @logger.debug "Obtaining crumb from the jenkins server"
708
+ api_get_request("/crumbIssuer")
709
+ rescue Exceptions::NotFoundException
710
+ raise Exceptions::CrumbNotFoundException.new(
711
+ @logger,
712
+ "CSRF protection is not enabled on the server at the moment." +
713
+ " Perhaps the client was initialized when the CSRF setting was" +
714
+ " enabled. Please re-initialize the client."
715
+ )
716
+ end
717
+ end
718
+
719
+ # Used to determine whether crumbs are enabled, and populate/clear our
720
+ # local crumb accordingly.
721
+ #
722
+ # @param force_refresh [Boolean] determines whether the check is
723
+ # cursory or deeper. The default is cursory - i.e. if crumbs
724
+ # enabled is 'nil' then figure out what to do, otherwise skip
725
+ # If 'true' the method will check to see if the crumbs require-
726
+ # ment has changed (by querying Jenkins), and updating crumb
727
+ # (refresh, delete, create) as appropriate.
728
+ #
729
+ def refresh_crumbs(force_refresh = false)
730
+ # Quick check to see if someone has changed XSS settings and not
731
+ # restarted us
732
+ if force_refresh || @crumbs_enabled.nil?
733
+ old_crumbs_setting = @crumbs_enabled
734
+ new_crumbs_setting = use_crumbs?
735
+
736
+ if old_crumbs_setting != new_crumbs_setting
737
+ @crumbs_enabled = new_crumbs_setting
738
+ end
739
+
740
+ # Get or clear crumbs setting appropriately
741
+ # Works as refresh if crumbs still enabled
742
+ if @crumbs_enabled
743
+ if old_crumbs_setting
744
+ @logger.info "Crumb expired. Refetching from the server."
745
+ else
746
+ @logger.info "Crumbs turned on. Fetching from the server."
747
+ end
748
+
749
+ @crumb = get_crumb if force_refresh || !old_crumbs_setting
750
+ else
751
+ if old_crumbs_setting
752
+ @logger.info "Crumbs turned off. Clearing crumb."
753
+ @crumb.clear
754
+ end
755
+ end
756
+ end
757
+ end
758
+
759
+ # Private method. Converts keys passed in as strings into symbols.
760
+ #
761
+ # @param hash [Hash] Hash containing arguments to login to jenkins.
762
+ #
763
+ def symbolize_keys(hash)
764
+ hash.inject({}){|result, (key, value)|
765
+ new_key = case key
766
+ when String then key.to_sym
767
+ else key
768
+ end
769
+ new_value = case value
770
+ when Hash then symbolize_keys(value)
771
+ else value
772
+ end
773
+ result[new_key] = new_value
774
+ result
775
+ }
776
+ end
777
+
778
+ # Private method that handles the exception and raises with proper error
779
+ # message with the type of exception and returns the required values if no
780
+ # exceptions are raised.
781
+ #
782
+ # @param [Net::HTTP::Response] response Response from Jenkins
783
+ # @param [String] to_send What should be returned as a response. Allowed
784
+ # values: "code", "body", and "raw".
785
+ # @param [Boolean] send_json Boolean value used to determine whether to
786
+ # load the JSON or send the response as is.
787
+ #
788
+ # @return [String, Hash] Response returned whether loaded JSON or raw
789
+ # string
790
+ #
791
+ # @raise [Exceptions::Unauthorized] When invalid credentials are
792
+ # provided to connect to Jenkins
793
+ # @raise [Exceptions::NotFound] When the requested page on Jenkins is not
794
+ # found
795
+ # @raise [Exceptions::InternalServerError] When Jenkins returns a 500
796
+ # Internal Server Error
797
+ # @raise [Exceptions::ApiException] Any other exception returned from
798
+ # Jenkins that are not categorized in the API Client.
799
+ #
800
+ def handle_exception(response, to_send = "code", send_json = false)
801
+ msg = "HTTP Code: #{response.code}, Response Body: #{response.body}"
802
+ @logger.debug msg
803
+ case response.code.to_i
804
+ # As of Jenkins version 1.519, the job builds return a 201 status code
805
+ # with a Location HTTP header with the pointing the URL of the item in
806
+ # the queue.
807
+ when 200, 201, 302
808
+ if to_send == "body" && send_json
809
+ return JSON.parse(response.body)
810
+ elsif to_send == "body"
811
+ return response.body
812
+ elsif to_send == "code"
813
+ return response.code
814
+ elsif to_send == "raw"
815
+ return response
816
+ end
817
+ when 400
818
+ matched = response.body.match(/<p>(.*)<\/p>/)
819
+ api_message = matched[1] unless matched.nil?
820
+ @logger.debug "API message: #{api_message}"
821
+ case api_message
822
+ when /A job already exists with the name/
823
+ raise Exceptions::JobAlreadyExists.new(@logger, api_message)
824
+ when /A view already exists with the name/
825
+ raise Exceptions::ViewAlreadyExists.new(@logger, api_message)
826
+ when /Slave called .* already exists/
827
+ raise Exceptions::NodeAlreadyExists.new(@logger, api_message)
828
+ when /Nothing is submitted/
829
+ raise Exceptions::NothingSubmitted.new(@logger, api_message)
830
+ else
831
+ raise Exceptions::ApiException.new(@logger, api_message)
832
+ end
833
+ when 401
834
+ raise Exceptions::Unauthorized.new @logger
835
+ when 403
836
+ raise Exceptions::Forbidden.new @logger
837
+ when 404
838
+ raise Exceptions::NotFound.new @logger
839
+ when 500
840
+ matched = response.body.match(/Exception: (.*)<br>/)
841
+ api_message = matched[1] unless matched.nil?
842
+ @logger.debug "API message: #{api_message}"
843
+ raise Exceptions::InternalServerError.new(@logger, api_message)
844
+ when 503
845
+ raise Exceptions::ServiceUnavailable.new @logger
846
+ else
847
+ raise Exceptions::ApiException.new(
848
+ @logger,
849
+ "Error code #{response.code}"
850
+ )
851
+ end
852
+ end
853
+
854
+ end
855
+ end