Commit 9646e451 authored by Sean McGivern's avatar Sean McGivern

Merge branch '58375-proxy-service' into 'master'

Add a service to proxy calls to Prometheus API

See merge request gitlab-org/gitlab-ce!26840
parents 88bf73a7 7f529353
# frozen_string_literal: true
module Prometheus
class ProxyService < BaseService
include ReactiveCaching
include Gitlab::Utils::StrongMemoize
self.reactive_cache_key = ->(service) { service.cache_key }
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds
self.reactive_cache_lifetime = 1.minute
self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
attr_accessor :proxyable, :method, :path, :params
PROXY_SUPPORT = {
'query' => {
method: ['GET'],
params: %w(query time timeout)
},
'query_range' => {
method: ['GET'],
params: %w(query start end step timeout)
}
}.freeze
def self.from_cache(proxyable_class_name, proxyable_id, method, path, params)
proxyable_class = begin
proxyable_class_name.constantize
rescue NameError
nil
end
return unless proxyable_class
proxyable = proxyable_class.find(proxyable_id)
new(proxyable, method, path, params)
end
# proxyable can be any model which responds to .prometheus_adapter
# like Environment.
def initialize(proxyable, method, path, params)
@proxyable = proxyable
@path = path
# Convert ActionController::Parameters to hash because reactive_cache_worker
# does not play nice with ActionController::Parameters.
@params = filter_params(params, path).to_hash
@method = method
end
def id
nil
end
def execute
return cannot_proxy_response unless can_proxy?
return no_prometheus_response unless can_query?
with_reactive_cache(*cache_key) do |result|
result
end
end
def calculate_reactive_cache(proxyable_class_name, proxyable_id, method, path, params)
return no_prometheus_response unless can_query?
response = prometheus_client_wrapper.proxy(path, params)
success(http_status: response.code, body: response.body)
rescue Gitlab::PrometheusClient::Error => err
service_unavailable_response(err)
end
def cache_key
[@proxyable.class.name, @proxyable.id, @method, @path, @params]
end
private
def service_unavailable_response(exception)
error(exception.message, :service_unavailable)
end
def no_prometheus_response
error('No prometheus server found', :service_unavailable)
end
def cannot_proxy_response
error('Proxy support for this API is not available currently')
end
def prometheus_adapter
strong_memoize(:prometheus_adapter) do
@proxyable.prometheus_adapter
end
end
def prometheus_client_wrapper
prometheus_adapter&.prometheus_client_wrapper
end
def can_query?
prometheus_adapter&.can_query?
end
def filter_params(params, path)
params.slice(*PROXY_SUPPORT.dig(path, :params))
end
def can_proxy?
PROXY_SUPPORT.dig(@path, :method)&.include?(@method)
end
end
end
...@@ -24,6 +24,19 @@ module Gitlab ...@@ -24,6 +24,19 @@ module Gitlab
json_api_get('query', query: '1') json_api_get('query', query: '1')
end end
def proxy(type, args)
path = api_path(type)
get(path, args)
rescue RestClient::ExceptionWithResponse => ex
if ex.response
ex.response
else
raise PrometheusClient::Error, "Network connection error"
end
rescue RestClient::Exception
raise PrometheusClient::Error, "Network connection error"
end
def query(query, time: Time.now) def query(query, time: Time.now)
get_result('vector') do get_result('vector') do
json_api_get('query', query: query, time: time.to_f) json_api_get('query', query: query, time: time.to_f)
...@@ -64,22 +77,14 @@ module Gitlab ...@@ -64,22 +77,14 @@ module Gitlab
private private
def json_api_get(type, args = {}) def api_path(type)
path = ['api', 'v1', type].join('/') ['api', 'v1', type].join('/')
get(path, args)
rescue JSON::ParserError
raise PrometheusClient::Error, 'Parsing response failed'
rescue Errno::ECONNREFUSED
raise PrometheusClient::Error, 'Connection refused'
end end
def get(path, args) def json_api_get(type, args = {})
response = rest_client[path].get(params: args) path = api_path(type)
response = get(path, args)
handle_response(response) handle_response(response)
rescue SocketError
raise PrometheusClient::Error, "Can't connect to #{rest_client.url}"
rescue OpenSSL::SSL::SSLError
raise PrometheusClient::Error, "#{rest_client.url} contains invalid SSL data"
rescue RestClient::ExceptionWithResponse => ex rescue RestClient::ExceptionWithResponse => ex
if ex.response if ex.response
handle_exception_response(ex.response) handle_exception_response(ex.response)
...@@ -90,8 +95,18 @@ module Gitlab ...@@ -90,8 +95,18 @@ module Gitlab
raise PrometheusClient::Error, "Network connection error" raise PrometheusClient::Error, "Network connection error"
end end
def get(path, args)
rest_client[path].get(params: args)
rescue SocketError
raise PrometheusClient::Error, "Can't connect to #{rest_client.url}"
rescue OpenSSL::SSL::SSLError
raise PrometheusClient::Error, "#{rest_client.url} contains invalid SSL data"
rescue Errno::ECONNREFUSED
raise PrometheusClient::Error, 'Connection refused'
end
def handle_response(response) def handle_response(response)
json_data = JSON.parse(response.body) json_data = parse_json(response.body)
if response.code == 200 && json_data['status'] == 'success' if response.code == 200 && json_data['status'] == 'success'
json_data['data'] || {} json_data['data'] || {}
else else
...@@ -103,7 +118,7 @@ module Gitlab ...@@ -103,7 +118,7 @@ module Gitlab
if response.code == 200 && response['status'] == 'success' if response.code == 200 && response['status'] == 'success'
response['data'] || {} response['data'] || {}
elsif response.code == 400 elsif response.code == 400
json_data = JSON.parse(response.body) json_data = parse_json(response.body)
raise PrometheusClient::QueryError, json_data['error'] || 'Bad data received' raise PrometheusClient::QueryError, json_data['error'] || 'Bad data received'
else else
raise PrometheusClient::Error, "#{response.code} - #{response.body}" raise PrometheusClient::Error, "#{response.code} - #{response.body}"
...@@ -114,5 +129,11 @@ module Gitlab ...@@ -114,5 +129,11 @@ module Gitlab
data = yield data = yield
data['result'] if data['resultType'] == expected_type data['result'] if data['resultType'] == expected_type
end end
def parse_json(response_body)
JSON.parse(response_body)
rescue JSON::ParserError
raise PrometheusClient::Error, 'Parsing response failed'
end
end end
end end
...@@ -60,15 +60,13 @@ describe Gitlab::PrometheusClient do ...@@ -60,15 +60,13 @@ describe Gitlab::PrometheusClient do
end end
describe 'failure to reach a provided prometheus url' do describe 'failure to reach a provided prometheus url' do
let(:prometheus_url) {"https://prometheus.invalid.example.com"} let(:prometheus_url) {"https://prometheus.invalid.example.com/api/v1/query?query=1"}
subject { described_class.new(RestClient::Resource.new(prometheus_url)) } shared_examples 'exceptions are raised' do
context 'exceptions are raised' do
it 'raises a Gitlab::PrometheusClient::Error error when a SocketError is rescued' do it 'raises a Gitlab::PrometheusClient::Error error when a SocketError is rescued' do
req_stub = stub_prometheus_request_with_exception(prometheus_url, SocketError) req_stub = stub_prometheus_request_with_exception(prometheus_url, SocketError)
expect { subject.send(:get, '/', {}) } expect { subject }
.to raise_error(Gitlab::PrometheusClient::Error, "Can't connect to #{prometheus_url}") .to raise_error(Gitlab::PrometheusClient::Error, "Can't connect to #{prometheus_url}")
expect(req_stub).to have_been_requested expect(req_stub).to have_been_requested
end end
...@@ -76,7 +74,7 @@ describe Gitlab::PrometheusClient do ...@@ -76,7 +74,7 @@ describe Gitlab::PrometheusClient do
it 'raises a Gitlab::PrometheusClient::Error error when a SSLError is rescued' do it 'raises a Gitlab::PrometheusClient::Error error when a SSLError is rescued' do
req_stub = stub_prometheus_request_with_exception(prometheus_url, OpenSSL::SSL::SSLError) req_stub = stub_prometheus_request_with_exception(prometheus_url, OpenSSL::SSL::SSLError)
expect { subject.send(:get, '/', {}) } expect { subject }
.to raise_error(Gitlab::PrometheusClient::Error, "#{prometheus_url} contains invalid SSL data") .to raise_error(Gitlab::PrometheusClient::Error, "#{prometheus_url} contains invalid SSL data")
expect(req_stub).to have_been_requested expect(req_stub).to have_been_requested
end end
...@@ -84,11 +82,23 @@ describe Gitlab::PrometheusClient do ...@@ -84,11 +82,23 @@ describe Gitlab::PrometheusClient do
it 'raises a Gitlab::PrometheusClient::Error error when a RestClient::Exception is rescued' do it 'raises a Gitlab::PrometheusClient::Error error when a RestClient::Exception is rescued' do
req_stub = stub_prometheus_request_with_exception(prometheus_url, RestClient::Exception) req_stub = stub_prometheus_request_with_exception(prometheus_url, RestClient::Exception)
expect { subject.send(:get, '/', {}) } expect { subject }
.to raise_error(Gitlab::PrometheusClient::Error, "Network connection error") .to raise_error(Gitlab::PrometheusClient::Error, "Network connection error")
expect(req_stub).to have_been_requested expect(req_stub).to have_been_requested
end end
end end
context 'ping' do
subject { described_class.new(RestClient::Resource.new(prometheus_url)).ping }
it_behaves_like 'exceptions are raised'
end
context 'proxy' do
subject { described_class.new(RestClient::Resource.new(prometheus_url)).proxy('query', { query: '1' }) }
it_behaves_like 'exceptions are raised'
end
end end
describe '#query' do describe '#query' do
...@@ -258,4 +268,59 @@ describe Gitlab::PrometheusClient do ...@@ -258,4 +268,59 @@ describe Gitlab::PrometheusClient do
it { is_expected.to eq(step) } it { is_expected.to eq(step) }
end end
end end
describe 'proxy' do
context 'get API' do
let(:prometheus_query) { prometheus_cpu_query('env-slug') }
let(:query_url) { prometheus_query_url(prometheus_query) }
around do |example|
Timecop.freeze { example.run }
end
context 'when response status code is 200' do
it 'returns response object' do
req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('vector'))
response = subject.proxy('query', { query: prometheus_query })
json_response = JSON.parse(response.body)
expect(response.code).to eq(200)
expect(json_response).to eq({
'status' => 'success',
'data' => {
'resultType' => 'vector',
'result' => [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }]
}
})
expect(req_stub).to have_been_requested
end
end
context 'when response status code is not 200' do
it 'returns response object' do
req_stub = stub_prometheus_request(query_url, status: 400, body: { error: 'error' })
response = subject.proxy('query', { query: prometheus_query })
json_response = JSON.parse(response.body)
expect(req_stub).to have_been_requested
expect(response.code).to eq(400)
expect(json_response).to eq('error' => 'error')
end
end
context 'when RestClient::Exception is raised' do
before do
stub_prometheus_request_with_exception(query_url, RestClient::Exception)
end
it 'raises PrometheusClient::Error' do
expect { subject.proxy('query', { query: prometheus_query }) }.to(
raise_error(Gitlab::PrometheusClient::Error, 'Network connection error')
)
end
end
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
describe Prometheus::ProxyService do
include ReactiveCachingHelpers
set(:project) { create(:project) }
set(:environment) { create(:environment, project: project) }
describe '#initialize' do
let(:params) { ActionController::Parameters.new(query: '1').permit! }
it 'initializes attributes' do
result = described_class.new(environment, 'GET', 'query', params)
expect(result.proxyable).to eq(environment)
expect(result.method).to eq('GET')
expect(result.path).to eq('query')
expect(result.params).to eq('query' => '1')
end
it 'converts ActionController::Parameters into hash' do
result = described_class.new(environment, 'GET', 'query', params)
expect(result.params).to be_an_instance_of(Hash)
end
context 'with unknown params' do
let(:params) { ActionController::Parameters.new(query: '1', other_param: 'val').permit! }
it 'filters unknown params' do
result = described_class.new(environment, 'GET', 'query', params)
expect(result.params).to eq('query' => '1')
end
end
end
describe '#execute' do
let(:prometheus_adapter) { instance_double(PrometheusService) }
let(:params) { ActionController::Parameters.new(query: '1').permit! }
subject { described_class.new(environment, 'GET', 'query', params) }
context 'when prometheus_adapter is nil' do
before do
allow(environment).to receive(:prometheus_adapter).and_return(nil)
end
it 'returns error' do
expect(subject.execute).to eq(
status: :error,
message: 'No prometheus server found',
http_status: :service_unavailable
)
end
end
context 'when prometheus_adapter cannot query' do
before do
allow(environment).to receive(:prometheus_adapter).and_return(prometheus_adapter)
allow(prometheus_adapter).to receive(:can_query?).and_return(false)
end
it 'returns error' do
expect(subject.execute).to eq(
status: :error,
message: 'No prometheus server found',
http_status: :service_unavailable
)
end
end
context 'cannot proxy' do
subject { described_class.new(environment, 'POST', 'garbage', params) }
it 'returns error' do
expect(subject.execute).to eq(
message: 'Proxy support for this API is not available currently',
status: :error
)
end
end
context 'with caching', :use_clean_rails_memory_store_caching do
let(:return_value) { { 'http_status' => 200, 'body' => 'body' } }
let(:opts) do
[environment.class.name, environment.id, 'GET', 'query', { 'query' => '1' }]
end
before do
allow(environment).to receive(:prometheus_adapter)
.and_return(prometheus_adapter)
allow(prometheus_adapter).to receive(:can_query?).and_return(true)
end
context 'when value present in cache' do
before do
stub_reactive_cache(subject, return_value, opts)
end
it 'returns cached value' do
result = subject.execute
expect(result[:http_status]).to eq(return_value[:http_status])
expect(result[:body]).to eq(return_value[:body])
end
end
context 'when value not present in cache' do
it 'returns nil' do
expect(ReactiveCachingWorker)
.to receive(:perform_async)
.with(subject.class, subject.id, *opts)
result = subject.execute
expect(result).to eq(nil)
end
end
end
context 'call prometheus api' do
let(:prometheus_client) { instance_double(Gitlab::PrometheusClient) }
before do
synchronous_reactive_cache(subject)
allow(environment).to receive(:prometheus_adapter)
.and_return(prometheus_adapter)
allow(prometheus_adapter).to receive(:can_query?).and_return(true)
allow(prometheus_adapter).to receive(:prometheus_client_wrapper)
.and_return(prometheus_client)
end
context 'connection to prometheus server succeeds' do
let(:rest_client_response) { instance_double(RestClient::Response) }
let(:prometheus_http_status_code) { 400 }
let(:response_body) do
'{"status":"error","errorType":"bad_data","error":"parse error at char 1: no expression found in input"}'
end
before do
allow(prometheus_client).to receive(:proxy).and_return(rest_client_response)
allow(rest_client_response).to receive(:code)
.and_return(prometheus_http_status_code)
allow(rest_client_response).to receive(:body).and_return(response_body)
end
it 'returns the http status code and body from prometheus' do
expect(subject.execute).to eq(
http_status: prometheus_http_status_code,
body: response_body,
status: :success
)
end
end
context 'connection to prometheus server fails' do
context 'prometheus client raises Gitlab::PrometheusClient::Error' do
before do
allow(prometheus_client).to receive(:proxy)
.and_raise(Gitlab::PrometheusClient::Error, 'Network connection error')
end
it 'returns error' do
expect(subject.execute).to eq(
status: :error,
message: 'Network connection error',
http_status: :service_unavailable
)
end
end
end
end
end
describe '.from_cache' do
it 'initializes an instance of ProxyService class' do
result = described_class.from_cache(
environment.class.name, environment.id, 'GET', 'query', { 'query' => '1' }
)
expect(result).to be_an_instance_of(described_class)
expect(result.proxyable).to eq(environment)
expect(result.method).to eq('GET')
expect(result.path).to eq('query')
expect(result.params).to eq('query' => '1')
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment