summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAsh McKenzie <amckenzie@gitlab.com>2018-08-20 14:45:31 +1000
committerAsh McKenzie <amckenzie@gitlab.com>2018-09-08 00:52:57 +1000
commit6fd4f6804125c0992296d064f3c074d39efeb091 (patch)
treecfdaf9a1f6ea83fb249a712c0f9704d6397ffb64
parent1ff3561895f361b79f46a059880ea122e3500d08 (diff)
downloadgitlab-shell-ash.mckenzie/custom-action-support.tar.gz
-rw-r--r--lib/action.rb4
-rw-r--r--lib/action/custom.rb127
-rw-r--r--lib/gitlab_access_status.rb17
-rw-r--r--lib/gitlab_net.rb6
-rw-r--r--lib/gitlab_shell.rb38
-rw-r--r--spec/action/custom_spec.rb136
-rw-r--r--spec/gitlab_shell_spec.rb23
-rw-r--r--spec/vcr_cassettes/custom-action-not-ok-json.yml40
-rw-r--r--spec/vcr_cassettes/custom-action-not-ok-not-json.yml40
-rw-r--r--spec/vcr_cassettes/custom-action-ok-not-json.yml51
-rw-r--r--spec/vcr_cassettes/custom-action-ok.yml99
11 files changed, 564 insertions, 17 deletions
diff --git a/lib/action.rb b/lib/action.rb
new file mode 100644
index 0000000..28c1c14
--- /dev/null
+++ b/lib/action.rb
@@ -0,0 +1,4 @@
+require_relative 'action/custom'
+
+module Action
+end
diff --git a/lib/action/custom.rb b/lib/action/custom.rb
new file mode 100644
index 0000000..83ea5fc
--- /dev/null
+++ b/lib/action/custom.rb
@@ -0,0 +1,127 @@
+require 'base64'
+
+require_relative '../http_helper'
+
+module Action
+ class Custom
+ include HTTPHelper
+
+ class BaseError < StandardError; end
+ class MissingPayloadError < BaseError; end
+ class MissingAPIEndpointsError < BaseError; end
+ class MissingDataError < BaseError; end
+ class UnsuccessfulError < BaseError; end
+
+ NO_MESSAGE_TEXT = 'No message'.freeze
+ DEFAULT_HEADERS = { 'Content-Type' => 'application/json' }.freeze
+
+ def initialize(gl_id, payload)
+ @gl_id = gl_id
+ @payload = payload
+ end
+
+ def execute
+ validate!
+ result = process_api_endpoints
+
+ if result && HTTP_SUCCESS_CODES.include?(result.code)
+ result
+ else
+ raise_unsuccessful!(result)
+ end
+ end
+
+ private
+
+ attr_reader :gl_id, :payload
+
+ def process_api_endpoints
+ output = ''
+ resp = nil
+
+ data_with_gl_id = data.merge('gl_id' => gl_id)
+
+ api_endpoints.each do |endpoint|
+ url = "#{base_url}#{endpoint}"
+ json = { 'data' => data_with_gl_id, 'output' => output }
+
+ resp = post(url, {}, headers: DEFAULT_HEADERS, options: { json: json })
+ return resp unless HTTP_SUCCESS_CODES.include?(resp.code)
+
+ begin
+ body = JSON.parse(resp.body)
+ rescue JSON::ParserError
+ raise UnsuccessfulError, 'Response was not valid JSON'
+ end
+
+ print_flush(body['result'])
+
+ # In the context of the git push sequence of events, it's necessary to read
+ # stdin in order to capture output to pass onto subsequent commands
+ output = read_stdin
+ end
+
+ resp
+ end
+
+ def base_url
+ config.gitlab_url
+ end
+
+ def data
+ @data ||= payload['data']
+ end
+
+ def api_endpoints
+ data['api_endpoints']
+ end
+
+ def config
+ @config ||= GitlabConfig.new
+ end
+
+ def api
+ @api ||= GitlabNet.new
+ end
+
+ def read_stdin
+ Base64.encode64($stdin.read)
+ end
+
+ def print_flush(str)
+ return false unless str
+ print(Base64.decode64(str))
+ STDOUT.flush
+ end
+
+ def validate!
+ validate_payload!
+ validate_data!
+ validate_api_endpoints!
+ end
+
+ def validate_payload!
+ raise MissingPayloadError if !payload.is_a?(Hash) || payload.empty?
+ end
+
+ def validate_data!
+ raise MissingDataError unless data.is_a?(Hash)
+ end
+
+ def validate_api_endpoints!
+ raise MissingAPIEndpointsError if !api_endpoints.is_a?(Array) ||
+ api_endpoints.empty?
+ end
+
+ def raise_unsuccessful!(result)
+ message = begin
+ body = JSON.parse(result.body)
+ body['message'] || Base64.decode64(body['result']) || NO_MESSAGE_TEXT
+ rescue JSON::ParserError
+ NO_MESSAGE_TEXT
+ end
+
+ raise UnsuccessfulError, "#{message} (#{result.code})"
+ end
+ end
+end
diff --git a/lib/gitlab_access_status.rb b/lib/gitlab_access_status.rb
index 68fbba1..8483863 100644
--- a/lib/gitlab_access_status.rb
+++ b/lib/gitlab_access_status.rb
@@ -1,9 +1,14 @@
require 'json'
+require_relative 'http_codes'
class GitAccessStatus
- attr_reader :message, :gl_repository, :gl_id, :gl_username, :gitaly, :git_protocol, :git_config_options
+ include HTTPCodes
- def initialize(status, status_code, message, gl_repository: nil, gl_id: nil, gl_username: nil, gitaly: nil, git_protocol: nil, git_config_options: nil)
+ attr_reader :message, :gl_repository, :gl_id, :gl_username, :gitaly, :git_protocol, :git_config_options, :payload
+
+ def initialize(status, status_code, message, gl_repository: nil, gl_id: nil,
+ gl_username: nil, gitaly: nil, git_protocol: nil,
+ git_config_options: nil, payload: nil)
@status = status
@status_code = status_code
@message = message
@@ -13,6 +18,7 @@ class GitAccessStatus
@git_config_options = git_config_options
@gitaly = gitaly
@git_protocol = git_protocol
+ @payload = payload
end
def self.create_from_json(json, status_code)
@@ -25,10 +31,15 @@ class GitAccessStatus
gl_username: values["gl_username"],
git_config_options: values["git_config_options"],
gitaly: values["gitaly"],
- git_protocol: values["git_protocol"])
+ git_protocol: values["git_protocol"],
+ payload: values["payload"])
end
def allowed?
@status
end
+
+ def custom_action?
+ @status_code == HTTP_MULTIPLE_CHOICES
+ end
end
diff --git a/lib/gitlab_net.rb b/lib/gitlab_net.rb
index 080898e..57ae452 100644
--- a/lib/gitlab_net.rb
+++ b/lib/gitlab_net.rb
@@ -33,9 +33,9 @@ class GitlabNet # rubocop:disable Metrics/ClassLength
url = "#{internal_api_endpoint}/allowed"
resp = post(url, params)
- case resp.code.to_s
- when HTTP_SUCCESS, HTTP_UNAUTHORIZED, HTTP_NOT_FOUND
- GitAccessStatus.create_from_json(resp.body)
+ case resp.code
+ when HTTP_SUCCESS, HTTP_MULTIPLE_CHOICES, HTTP_UNAUTHORIZED, HTTP_NOT_FOUND
+ GitAccessStatus.create_from_json(resp.body, resp.code)
else
GitAccessStatus.new(false, resp.code, 'API is not accessible')
end
diff --git a/lib/gitlab_shell.rb b/lib/gitlab_shell.rb
index ba5baf7..79af861 100644
--- a/lib/gitlab_shell.rb
+++ b/lib/gitlab_shell.rb
@@ -5,6 +5,7 @@ require 'pathname'
require_relative 'gitlab_net'
require_relative 'gitlab_metrics'
+require_relative 'action'
class GitlabShell # rubocop:disable Metrics/ClassLength
class AccessDeniedError < StandardError; end
@@ -50,8 +51,17 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
args = Shellwords.shellwords(origin_cmd)
args = parse_cmd(args)
+ access_status = nil
+
if GIT_COMMANDS.include?(args.first)
- GitlabMetrics.measure('verify-access') { verify_access }
+ access_status = GitlabMetrics.measure('verify-access') { verify_access }
+
+ @gl_repository = access_status.gl_repository
+ @git_protocol = ENV['GIT_PROTOCOL']
+ @gitaly = access_status.gitaly
+ @username = access_status.gl_username
+ @git_config_options = access_status.git_config_options
+ @gl_id = access_status.gl_id if defined?(@who)
elsif !defined?(@gl_id)
# We're processing an API command like 2fa_recovery_codes, but
# don't have a @gl_id yet, that means we're in the "username"
@@ -60,6 +70,13 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
user
end
+ if @command == GIT_RECEIVE_PACK_COMMAND && access_status.custom_action?
+ # If the response from /api/v4/allowed is a HTTP 300, we need to perform
+ # a Custom Action and therefore should return and not call process_cmd()
+ #
+ return process_custom_action(access_status)
+ end
+
process_cmd(args)
true
@@ -68,17 +85,19 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
false
rescue AccessDeniedError => ex
$logger.warn('Access denied', command: origin_cmd, user: log_username)
-
$stderr.puts "GitLab: #{ex.message}"
false
rescue DisallowedCommandError
$logger.warn('Denied disallowed command', command: origin_cmd, user: log_username)
-
$stderr.puts "GitLab: Disallowed command"
false
rescue InvalidRepositoryPathError
$stderr.puts "GitLab: Invalid repository path"
false
+ rescue Action::Custom::BaseError => ex
+ $logger.warn('Custom action error', command: origin_cmd, user: log_username)
+ $stderr.puts "GitLab: #{ex.message}"
+ false
end
protected
@@ -123,14 +142,11 @@ class GitlabShell # rubocop:disable Metrics/ClassLength
raise AccessDeniedError, status.message unless status.allowed?
- @gl_repository = status.gl_repository
- @git_protocol = ENV['GIT_PROTOCOL']
- @gitaly = status.gitaly
- @username = status.gl_username
- @git_config_options = status.git_config_options
- if defined?(@who)
- @gl_id = status.gl_id
- end
+ status
+ end
+
+ def process_custom_action(access_status)
+ Action::Custom.new(@gl_id, access_status.payload).execute
end
def process_cmd(args)
diff --git a/spec/action/custom_spec.rb b/spec/action/custom_spec.rb
new file mode 100644
index 0000000..e85df71
--- /dev/null
+++ b/spec/action/custom_spec.rb
@@ -0,0 +1,136 @@
+require_relative '../spec_helper'
+require_relative '../../lib/action/custom'
+
+describe Action::Custom do
+ let(:repo_name) { 'gitlab-ci.git' }
+ let(:gl_id) { 'key-1' }
+ let(:secret) { "0a3938d9d95d807e94d937af3a4fbbea" }
+ let(:base_url) { 'http://localhost:3000' }
+
+ subject { described_class.new(gl_id, payload) }
+
+ describe '#execute' do
+ context 'with an empty payload' do
+ let(:payload) { {} }
+
+ it 'raises a MissingPayloadError exception' do
+ expect { subject.execute }.to raise_error(Action::Custom::MissingPayloadError)
+ end
+ end
+
+ context 'with api_endpoints defined' do
+ before do
+ allow(subject).to receive(:base_url).and_return(base_url)
+ allow(subject).to receive(:secret_token).and_return(secret)
+ allow($stdin).to receive(:read).and_return('')
+ end
+
+ context 'that are valid' do
+ let(:payload) do
+ {
+ 'action' => 'geo_proxy_to_primary',
+ 'data' => {
+ 'api_endpoints' => %w{/api/v4/fake/info_refs /api/v4/fake/push},
+ 'gl_username' => 'user1',
+ 'primary_repo' => 'http://localhost:3001/user1/repo1.git'
+ }
+ }
+ end
+
+ context 'and responds correctly' do
+ it 'returns an instance of Net::HTTPCreated' do
+ VCR.use_cassette("custom-action-ok") do
+ expect(subject.execute).to be_instance_of(Net::HTTPCreated)
+ end
+ end
+ end
+
+ context 'but responds incorrectly' do
+ it 'raises an UnsuccessfulError exception' do
+ VCR.use_cassette("custom-action-ok-not-json") do
+ expect {
+ subject.execute
+ }.to raise_error(Action::Custom::UnsuccessfulError, 'Response was not valid JSON')
+ end
+ end
+ end
+ end
+
+ context 'that are invalid' do
+ context 'where api_endpoints gl_id is missing' do
+ let(:payload) do
+ {
+ 'action' => 'geo_proxy_to_primary',
+ 'data' => {
+ 'gl_username' => 'user1',
+ 'primary_repo' => 'http://localhost:3001/user1/repo1.git'
+ }
+ }
+ end
+
+ it 'raises a MissingAPIEndpointsError exception' do
+ expect { subject.execute }.to raise_error(Action::Custom::MissingAPIEndpointsError)
+ end
+ end
+
+ context 'where api_endpoints are empty' do
+ let(:payload) do
+ {
+ 'action' => 'geo_proxy_to_primary',
+ 'data' => {
+ 'api_endpoints' => [],
+ 'gl_username' => 'user1',
+ 'primary_repo' => 'http://localhost:3001/user1/repo1.git'
+ }
+ }
+ end
+
+ it 'raises a MissingAPIEndpointsError exception' do
+ expect { subject.execute }.to raise_error(Action::Custom::MissingAPIEndpointsError)
+ end
+ end
+
+ context 'where data gl_id is missing' do
+ let(:payload) { { 'api_endpoints' => %w{/api/v4/fake/info_refs /api/v4/fake/push} } }
+
+ it 'raises a MissingDataError exception' do
+ expect { subject.execute }.to raise_error(Action::Custom::MissingDataError)
+ end
+ end
+
+ context 'where API endpoints are bad' do
+ let(:payload) do
+ {
+ 'action' => 'geo_proxy_to_primary',
+ 'data' => {
+ 'api_endpoints' => %w{/api/v4/fake/info_refs_bad /api/v4/fake/push_bad},
+ 'gl_username' => 'user1',
+ 'primary_repo' => 'http://localhost:3001/user1/repo1.git'
+ }
+ }
+ end
+
+ context 'and response is JSON' do
+ it 'raises an UnsuccessfulError exception' do
+ VCR.use_cassette("custom-action-not-ok-json") do
+ expect {
+ subject.execute
+ }.to raise_error(Action::Custom::UnsuccessfulError, 'You cannot perform write operations on a read-only instance (403)')
+ end
+ end
+ end
+
+ context 'and response is not JSON' do
+ it 'raises an UnsuccessfulError exception' do
+ VCR.use_cassette("custom-action-not-ok-not-json") do
+ expect {
+ subject.execute
+ }.to raise_error(Action::Custom::UnsuccessfulError, 'No message (403)')
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/gitlab_shell_spec.rb b/spec/gitlab_shell_spec.rb
index 24fe151..77fb6cd 100644
--- a/spec/gitlab_shell_spec.rb
+++ b/spec/gitlab_shell_spec.rb
@@ -256,6 +256,29 @@ describe GitlabShell do
user_string = "user with id #{gl_id}"
expect($logger).to receive(:info).with(message, command: "gitaly-receive-pack unix:gitaly.socket #{gitaly_message}", user: user_string)
end
+
+ context 'with a custom action' do
+ let(:fake_payload) { { 'api_endpoints' => [ '/fake/api/endpoint' ], 'data' => {} } }
+ let(:custom_action_gitlab_access_status) do
+ GitAccessStatus.new(
+ true,
+ HTTPCodes::HTTP_MULTIPLE_CHOICES,
+ 'Multiple Choices',
+ payload: fake_payload
+ )
+ end
+ let(:action_custom) { double(Action::Custom) }
+
+ before do
+ allow(api).to receive(:check_access).and_return(custom_action_gitlab_access_status)
+ end
+
+ it "should not process the command" do
+ expect(subject).to_not receive(:process_cmd).with(%w(git-receive-pack gitlab-ci.git))
+ expect(Action::Custom).to receive(:new).with(gl_id, fake_payload).and_return(action_custom)
+ expect(action_custom).to receive(:execute)
+ end
+ end
end
context 'gitaly-receive-pack' do
diff --git a/spec/vcr_cassettes/custom-action-not-ok-json.yml b/spec/vcr_cassettes/custom-action-not-ok-json.yml
new file mode 100644
index 0000000..e50719d
--- /dev/null
+++ b/spec/vcr_cassettes/custom-action-not-ok-json.yml
@@ -0,0 +1,40 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: http://localhost:3000/api/v4/fake/info_refs_bad
+ body:
+ encoding: UTF-8
+ string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-11"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea\n"}'
+ headers:
+ Content-Type:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ User-Agent:
+ - Ruby
+ Host:
+ - localhost
+ response:
+ status:
+ code: 403
+ message: Forbidden
+ headers:
+ Date:
+ - Fri, 20 Jul 2018 06:54:21 GMT
+ Connection:
+ - close
+ Content-Type:
+ - application/json
+ X-Request-Id:
+ - ea0644ac-e1ad-45f6-aa72-cc7910274318
+ X-Runtime:
+ - '1.236672'
+ body:
+ encoding: UTF-8
+ string: '{"message":"You cannot perform write operations on a read-only instance"}'
+ http_version:
+ recorded_at: Fri, 20 Jul 2018 06:54:21 GMT
+recorded_with: VCR 2.4.0
diff --git a/spec/vcr_cassettes/custom-action-not-ok-not-json.yml b/spec/vcr_cassettes/custom-action-not-ok-not-json.yml
new file mode 100644
index 0000000..b60a93a
--- /dev/null
+++ b/spec/vcr_cassettes/custom-action-not-ok-not-json.yml
@@ -0,0 +1,40 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: http://localhost:3000/api/v4/fake/info_refs_bad
+ body:
+ encoding: UTF-8
+ string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-11"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea\n"}'
+ headers:
+ Content-Type:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ User-Agent:
+ - Ruby
+ Host:
+ - localhost
+ response:
+ status:
+ code: 403
+ message: Forbidden
+ headers:
+ Date:
+ - Fri, 20 Jul 2018 06:54:21 GMT
+ Connection:
+ - close
+ Content-Type:
+ - application/json
+ X-Request-Id:
+ - ea0644ac-e1ad-45f6-aa72-cc7910274318
+ X-Runtime:
+ - '1.236672'
+ body:
+ encoding: UTF-8
+ string: '""'
+ http_version:
+ recorded_at: Fri, 20 Jul 2018 06:54:21 GMT
+recorded_with: VCR 2.4.0
diff --git a/spec/vcr_cassettes/custom-action-ok-not-json.yml b/spec/vcr_cassettes/custom-action-ok-not-json.yml
new file mode 100644
index 0000000..3bfb390
--- /dev/null
+++ b/spec/vcr_cassettes/custom-action-ok-not-json.yml
@@ -0,0 +1,51 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: http://localhost:3000/api/v4/fake/info_refs
+ body:
+ encoding: UTF-8
+ string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-1"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea"}'
+ headers:
+ Content-Type:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ User-Agent:
+ - Ruby
+ Host:
+ - localhost
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Date:
+ - Fri, 20 Jul 2018 06:18:58 GMT
+ Connection:
+ - close
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Content-Type-Options:
+ - nosniff
+ Content-Type:
+ - application/json
+ Content-Length:
+ - '172'
+ Vary:
+ - Origin
+ Etag:
+ - W/"7d01e1e3dbcbe7cca9607461352f8244"
+ Cache-Control:
+ - max-age=0, private, must-revalidate
+ X-Request-Id:
+ - 03afa234-b6be-49ab-9392-4aa35c5dee25
+ X-Runtime:
+ - '1.436040'
+ body:
+ encoding: UTF-8
+ string: '""'
+ http_version:
+ recorded_at: Fri, 20 Jul 2018 06:18:58 GMT
diff --git a/spec/vcr_cassettes/custom-action-ok.yml b/spec/vcr_cassettes/custom-action-ok.yml
new file mode 100644
index 0000000..a057441
--- /dev/null
+++ b/spec/vcr_cassettes/custom-action-ok.yml
@@ -0,0 +1,99 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: http://localhost:3000/api/v4/fake/info_refs
+ body:
+ encoding: UTF-8
+ string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-1"},"output":"","secret_token":"0a3938d9d95d807e94d937af3a4fbbea"}'
+ headers:
+ Content-Type:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ User-Agent:
+ - Ruby
+ Host:
+ - localhost
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Date:
+ - Fri, 20 Jul 2018 06:18:58 GMT
+ Connection:
+ - close
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Content-Type-Options:
+ - nosniff
+ Content-Type:
+ - application/json
+ Content-Length:
+ - '172'
+ Vary:
+ - Origin
+ Etag:
+ - W/"7d01e1e3dbcbe7cca9607461352f8244"
+ Cache-Control:
+ - max-age=0, private, must-revalidate
+ X-Request-Id:
+ - 03afa234-b6be-49ab-9392-4aa35c5dee25
+ X-Runtime:
+ - '1.436040'
+ body:
+ encoding: UTF-8
+ string: '{"result":"info_refs-result"}'
+ http_version:
+ recorded_at: Fri, 20 Jul 2018 06:18:58 GMT
+- request:
+ method: post
+ uri: http://localhost:3000/api/v4/fake/push
+ body:
+ encoding: UTF-8
+ string: '{"data":{"gl_username":"user1","primary_repo":"http://localhost:3001/user1/repo1.git","gl_id":"key-1"},"output":"info_refs-result","secret_token":"0a3938d9d95d807e94d937af3a4fbbea"}'
+ headers:
+ Content-Type:
+ - application/json
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ User-Agent:
+ - Ruby
+ Host:
+ - localhost
+ response:
+ status:
+ code: 201
+ message: Created
+ headers:
+ Date:
+ - Fri, 20 Jul 2018 06:19:08 GMT
+ Connection:
+ - close
+ X-Frame-Options:
+ - SAMEORIGIN
+ X-Content-Type-Options:
+ - nosniff
+ Content-Type:
+ - application/json
+ Content-Length:
+ - '13'
+ Vary:
+ - Origin
+ Cache-Control:
+ - no-cache
+ X-Request-Id:
+ - 0c6894ac-7f8e-4cdb-871f-4cb64d3731ca
+ X-Runtime:
+ - '0.786754'
+ body:
+ encoding: UTF-8
+ string: '{"result":"push-result"}'
+ http_version:
+ recorded_at: Fri, 20 Jul 2018 06:19:08 GMT
+recorded_with: VCR 2.4.0