Class: JiraGateway

Inherits:
Object
  • Object
show all
Defined in:
lib/jirametrics/jira_gateway.rb

Defined Under Namespace

Classes: VerifyResult

Constant Summary collapse

RETRYABLE_EXIT_CODES =
[7, 28, 35, 56].freeze
MAX_RETRIES =
3
RETRY_DELAY_SECONDS =
5

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(file_system:, jira_config:, settings:) ⇒ JiraGateway

Returns a new instance of JiraGateway.



16
17
18
19
20
21
# File 'lib/jirametrics/jira_gateway.rb', line 16

def initialize file_system:, jira_config:, settings:
  @file_system = file_system
  load_jira_config(jira_config)
  @settings = settings
  @ignore_ssl_errors = settings['ignore_ssl_errors']
end

Instance Attribute Details

#file_systemObject (readonly)

Returns the value of attribute file_system.



10
11
12
# File 'lib/jirametrics/jira_gateway.rb', line 10

def file_system
  @file_system
end

#ignore_ssl_errorsObject

Returns the value of attribute ignore_ssl_errors.



9
10
11
# File 'lib/jirametrics/jira_gateway.rb', line 9

def ignore_ssl_errors
  @ignore_ssl_errors
end

#jira_urlObject (readonly)

Returns the value of attribute jira_url.



10
11
12
# File 'lib/jirametrics/jira_gateway.rb', line 10

def jira_url
  @jira_url
end

#settingsObject (readonly)

Returns the value of attribute settings.



10
11
12
# File 'lib/jirametrics/jira_gateway.rb', line 10

def settings
  @settings
end

Instance Method Details

#call_url(relative_url:) ⇒ Object



105
106
107
108
# File 'lib/jirametrics/jira_gateway.rb', line 105

def call_url relative_url:
  command = make_curl_command url: "#{@jira_url}#{relative_url}"
  exec_and_parse_response command: command, stdin_data: nil
end

#capture3(command, stdin_data:) ⇒ Object



90
91
92
93
# File 'lib/jirametrics/jira_gateway.rb', line 90

def capture3 command, stdin_data:
  # In it's own method so we can mock it out in tests
  Open3.capture3(command, stdin_data: stdin_data)
end

#cloud?Boolean

Returns:

  • (Boolean)


193
194
195
# File 'lib/jirametrics/jira_gateway.rb', line 193

def cloud?
  @jira_url.downcase.end_with? '.atlassian.net'
end

#exec_and_parse_response(command:, stdin_data:) ⇒ Object



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/jirametrics/jira_gateway.rb', line 28

def exec_and_parse_response command:, stdin_data:
  log_entry = "  #{command.gsub(/\s+/, ' ')}"
  log_entry = sanitize_message log_entry
  @file_system.log log_entry

  retries = 0
  loop do
    started = monotonic_time
    stdout, stderr, status = capture3(command, stdin_data: stdin_data)
    @file_system.diagnostic format('Jira call took %.2fs', monotonic_time - started)

    if status.success?
      @file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
      raise 'no response from curl on stdout' if stdout == ''

      return parse_response(command: command, result: stdout)
    end

    if retryable?(status: status, stderr: stderr, retries: retries)
      retries += 1
      @file_system.log "Transient network error (exit #{status.exitstatus}), retrying in " \
                       "#{RETRY_DELAY_SECONDS}s (attempt #{retries}/#{MAX_RETRIES})..."
      sleep_between_retries
      next
    end

    raise_for_failure status: status, stdout: stdout, stderr: stderr
  end
end

#failure_summary(status:, stderr:) ⇒ Object

Turn curl's opaque exit code into something self-describing: prefer the HTTP status Jira returned, then curl's own error text (e.g. "Could not resolve host"), falling back to the exit code only when neither is present.



80
81
82
83
84
85
86
87
88
# File 'lib/jirametrics/jira_gateway.rb', line 80

def failure_summary status:, stderr:
  http_status = stderr[/returned error: (\d{3})/, 1]
  return "Jira returned HTTP #{http_status}" if http_status

  curl_message = stderr[/curl: \(\d+\) (.+)/, 1]
  return "Jira call failed: #{curl_message.strip}" if curl_message

  "Failed call with exit status #{status.exitstatus}"
end

#json_successful?(json) ⇒ Boolean

Returns:

  • (Boolean)


186
187
188
189
190
191
# File 'lib/jirametrics/jira_gateway.rb', line 186

def json_successful? json
  return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
  return false if json.is_a?(Array) && json.first == 'errorMessage'

  true
end

#load_jira_config(jira_config) ⇒ Object



146
147
148
149
150
151
152
153
154
155
156
# File 'lib/jirametrics/jira_gateway.rb', line 146

def load_jira_config jira_config
  @jira_url = jira_config['url']
  raise 'Must specify URL in config' if @jira_url.nil?

  @jira_email = jira_config['email']
  @jira_api_token = jira_config['api_token']
  @jira_personal_access_token = jira_config['personal_access_token']
  validate_jira_auth

  @cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
end

#make_curl_command(url:, method: 'GET') ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/jirametrics/jira_gateway.rb', line 166

def make_curl_command url:, method: 'GET'
  command = +''
  command << 'curl'
  command << ' -L' # follow redirects
  command << ' -s' # silent
  command << ' -k' if @ignore_ssl_errors # insecure
  command << " --cookie #{@cookies.inspect}" unless @cookies.empty?
  command << " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
  command << " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
  command << " --request #{method}"
  if method == 'POST'
    command << ' --data @-'
    command << ' --header "Content-Type: application/json"'
  end
  command << ' --header "Accept: application/json"'
  command << ' --show-error --fail' # Better diagnostics when the server returns an error
  command << " --url \"#{url}\""
  command
end

#monotonic_timeObject



100
101
102
103
# File 'lib/jirametrics/jira_gateway.rb', line 100

def monotonic_time
  # In its own method so we can mock it out in tests
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

#parse_response(command:, result:) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/jirametrics/jira_gateway.rb', line 125

def parse_response command:, result:
  begin
    json = JSON.parse(result)
  rescue # rubocop:disable Style/RescueStandardError
    message = "Unable to parse results from #{sanitize_message(command)}"
    @file_system.error message, more: result
    raise message
  end

  raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)

  json
end

#post_request(relative_url:, payload:) ⇒ Object



23
24
25
26
# File 'lib/jirametrics/jira_gateway.rb', line 23

def post_request relative_url:, payload:
  command = make_curl_command url: "#{@jira_url}#{relative_url}", method: 'POST'
  exec_and_parse_response command: command, stdin_data: payload
end

#raise_for_failure(status:, stdout:, stderr:) ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/jirametrics/jira_gateway.rb', line 62

def raise_for_failure status:, stdout:, stderr:
  @file_system.error "Failed call with exit status #{status.exitstatus}!"
  @file_system.error "Returned (stdout): #{stdout.inspect}"
  @file_system.error "Returned (stderr): #{stderr.inspect}"
  if stderr.include?('401')
    raise 'The request was not authorized. Verify that your authentication token hasn\'t expired'
  end
  if stderr.include?('503')
    raise 'Jira returned 503 (Service Unavailable). This may be a temporary outage, or your ' \
          'Jira account may have been deactivated due to inactivity. Check your Jira subscription ' \
          'and try again later.'
  end
  raise "#{failure_summary(status: status, stderr: stderr)}. See #{@file_system.logfile_name} for details"
end

#retryable?(status:, stderr:, retries:) ⇒ Boolean

Returns:

  • (Boolean)


58
59
60
# File 'lib/jirametrics/jira_gateway.rb', line 58

def retryable? status:, stderr:, retries:
  RETRYABLE_EXIT_CODES.include?(status.exitstatus) && retries < MAX_RETRIES && !stderr.include?('503')
end

#sanitize_message(message) ⇒ Object



139
140
141
142
143
144
# File 'lib/jirametrics/jira_gateway.rb', line 139

def sanitize_message message
  token = @jira_api_token || @jira_personal_access_token
  return message unless token # cookie based authentication

  message.gsub(token, '[API_TOKEN]')
end

#sleep_between_retriesObject



95
96
97
98
# File 'lib/jirametrics/jira_gateway.rb', line 95

def sleep_between_retries
  # In its own method so we can mock it out in tests
  sleep RETRY_DELAY_SECONDS
end

#validate_jira_authObject



158
159
160
161
162
163
164
# File 'lib/jirametrics/jira_gateway.rb', line 158

def validate_jira_auth
  raise 'When specifying an api-token, you must also specify email' if @jira_api_token && !@jira_email

  return unless @jira_api_token && @jira_personal_access_token

  raise "You can't specify both an api-token and a personal-access-token. They don't work together."
end

#verify_connectionObject

Confirm that our credentials actually authenticate against Jira. We deliberately hit /myself, which requires authentication on every Jira edition (anonymous callers get a 401). An endpoint that also answers anonymously would happily "succeed" with bad credentials, which defeats the whole point. Cloud speaks v3, Server/Data Center speaks v2. Returns a VerifyResult rather than raising so the caller can report on every configured connection and set its own exit status.



117
118
119
120
121
122
123
# File 'lib/jirametrics/jira_gateway.rb', line 117

def verify_connection
  json = call_url relative_url: "/rest/api/#{cloud? ? 3 : 2}/myself"
  user = json['displayName'] || json['name'] || json['emailAddress'] || 'unknown user'
  VerifyResult.new(ok: true, url: @jira_url, message: "Verified #{@jira_url} (authenticated as #{user})")
rescue StandardError => e
  VerifyResult.new(ok: false, url: @jira_url, message: "Could not authenticate to #{@jira_url}: #{e.message}")
end