-
Notifications
You must be signed in to change notification settings - Fork 251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add marathon watcher #101
Merged
Merged
Add marathon watcher #101
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
ee0db47
Initial stab at Marathon watcher
tdooner 963cca9
Set a timeout of 5 seconds on the connection
tdooner 67b3168
Filter out Staged tasks
tdooner 5f72686
Always request application/json from marathon
tdooner 4358449
Add spec for marathon watcher
tdooner 8e5b63a
Sort tasks by name
tdooner acde0d3
Attempt marathon connection during watch loop
tdooner aa4cbc5
Merge remote-tracking branch 'upstream/master' into add_marathon_watcher
tdooner f41af12
Make the tests pass after merging upstream/master
tdooner f44cf91
Allow customizable marathon_api_path
tdooner 0252cac
Improve exception logic for connection problems
tdooner 93b992c
Add splay to initial Marathon watch
tdooner 0659aae
Remove refactored backend check logic
tdooner 806abbd
Add port index support to marathon watcher
tdooner b3d5368
Require net/http explicitly
tdooner File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
require 'synapse/service_watcher/base' | ||
require 'json' | ||
require 'net/http' | ||
require 'resolv' | ||
|
||
class Synapse::ServiceWatcher | ||
class MarathonWatcher < BaseWatcher | ||
def start | ||
@check_interval = @discovery['check_interval'] || 10.0 | ||
@connection = nil | ||
@watcher = Thread.new { sleep splay; watch } | ||
end | ||
|
||
def stop | ||
@connection.finish | ||
rescue | ||
# pass | ||
end | ||
|
||
private | ||
|
||
def validate_discovery_opts | ||
required_opts = %w[marathon_api_url application_name] | ||
|
||
required_opts.each do |opt| | ||
if @discovery.fetch(opt, '').empty? | ||
raise ArgumentError, | ||
"a value for services.#{@name}.discovery.#{opt} must be specified" | ||
end | ||
end | ||
end | ||
|
||
def attempt_marathon_connection | ||
marathon_api_path = @discovery.fetch('marathon_api_path', '/v2/apps/%{app}/tasks') | ||
marathon_api_path = marathon_api_path % { app: @discovery['application_name'] } | ||
|
||
@marathon_api = URI.join(@discovery['marathon_api_url'], marathon_api_path) | ||
|
||
begin | ||
@connection = Net::HTTP.new(@marathon_api.host, @marathon_api.port) | ||
@connection.open_timeout = 5 | ||
@connection.start | ||
rescue => ex | ||
@connection = nil | ||
log.error "synapse: could not connect to marathon at #{@marathon_api}: #{ex}" | ||
|
||
raise ex | ||
end | ||
end | ||
|
||
def watch | ||
until @should_exit | ||
retry_count = 0 | ||
start = Time.now | ||
|
||
begin | ||
if @connection.nil? | ||
attempt_marathon_connection | ||
end | ||
|
||
req = Net::HTTP::Get.new(@marathon_api.request_uri) | ||
req['Accept'] = 'application/json' | ||
response = @connection.request(req) | ||
|
||
tasks = JSON.parse(response.body).fetch('tasks', []) | ||
port_index = @discovery['port_index'] || 0 | ||
backends = tasks.keep_if { |task| task['startedAt'] }.map do |task| | ||
{ | ||
'name' => task['host'], | ||
'host' => task['host'], | ||
'port' => task['ports'][port_index], | ||
} | ||
end.sort_by { |task| task['name'] } | ||
|
||
invalid_backends = backends.find_all { |b| b['port'].nil? } | ||
if invalid_backends.any? | ||
backends = backends - invalid_backends | ||
|
||
invalid_backends.each do |backend| | ||
log.error "synapse: port index #{port_index} not found in task's port array!" | ||
end | ||
end | ||
|
||
set_backends(backends) | ||
rescue EOFError | ||
# If the persistent HTTP connection is severed, we can automatically | ||
# retry | ||
log.info "synapse: marathon HTTP API disappeared, reconnecting..." | ||
|
||
retry if (retry_count += 1) == 1 | ||
rescue => e | ||
log.warn "synapse: error in watcher thread: #{e.inspect}" | ||
log.warn e.backtrace.join("\n") | ||
@connection = nil | ||
ensure | ||
elapsed_time = Time.now - start | ||
sleep (@check_interval - elapsed_time) if elapsed_time < @check_interval | ||
end | ||
|
||
@should_exit = true if only_run_once? # for testability | ||
end | ||
end | ||
|
||
def splay | ||
Random.rand(@check_interval) | ||
end | ||
|
||
def only_run_once? | ||
false | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
require 'spec_helper' | ||
require 'synapse/service_watcher/marathon' | ||
|
||
describe Synapse::ServiceWatcher::MarathonWatcher do | ||
let(:mocksynapse) { double() } | ||
let(:marathon_host) { '127.0.0.1' } | ||
let(:marathon_port) { '8080' } | ||
let(:app_name) { 'foo' } | ||
let(:check_interval) { 11 } | ||
let(:marathon_request_uri) { "#{marathon_host}:#{marathon_port}/v2/apps/#{app_name}/tasks" } | ||
let(:config) do | ||
{ | ||
'name' => 'foo', | ||
'discovery' => { | ||
'method' => 'marathon', | ||
'marathon_api_url' => "http://#{marathon_host}:#{marathon_port}", | ||
'application_name' => app_name, | ||
'check_interval' => check_interval, | ||
}, | ||
'haproxy' => {}, | ||
} | ||
end | ||
let(:marathon_response) { { 'tasks' => [] } } | ||
|
||
subject { described_class.new(config, mocksynapse) } | ||
|
||
before do | ||
allow(subject.log).to receive(:warn) | ||
allow(subject.log).to receive(:info) | ||
|
||
allow(Thread).to receive(:new).and_yield | ||
allow(subject).to receive(:sleep) | ||
allow(subject).to receive(:only_run_once?).and_return(true) | ||
allow(subject).to receive(:splay).and_return(0) | ||
|
||
stub_request(:get, marathon_request_uri). | ||
with(:headers => { 'Accept' => 'application/json' }). | ||
to_return(:body => JSON.generate(marathon_response)) | ||
end | ||
|
||
context 'with a valid argument hash' do | ||
it 'instantiates' do | ||
expect(subject).to be_a(Synapse::ServiceWatcher::MarathonWatcher) | ||
end | ||
end | ||
|
||
describe '#watch' do | ||
context 'when synapse cannot connect to marathon' do | ||
before do | ||
allow(Net::HTTP).to receive(:new). | ||
with(marathon_host, marathon_port.to_i). | ||
and_raise(Errno::ECONNREFUSED) | ||
end | ||
|
||
it 'does not crash' do | ||
expect { subject.start }.not_to raise_error | ||
end | ||
end | ||
|
||
it 'requests the proper API endpoint one time' do | ||
subject.start | ||
expect(a_request(:get, marathon_request_uri)).to have_been_made.times(1) | ||
end | ||
|
||
context 'when the API path (marathon_api_path) is customized' do | ||
let(:config) do | ||
super().tap do |c| | ||
c['discovery']['marathon_api_path'] = '/v3/tasks/%{app}' | ||
end | ||
end | ||
|
||
let(:marathon_request_uri) { "#{marathon_host}:#{marathon_port}/v3/tasks/#{app_name}" } | ||
|
||
it 'calls the customized path' do | ||
subject.start | ||
expect(a_request(:get, marathon_request_uri)).to have_been_made.times(1) | ||
end | ||
end | ||
|
||
context 'with tasks returned from marathon' do | ||
let(:marathon_response) do | ||
{ | ||
'tasks' => [ | ||
{ | ||
'host' => 'agouti.local', | ||
'id' => 'my-app_1-1396592790353', | ||
'ports' => [ | ||
31336, | ||
31337 | ||
], | ||
'stagedAt' => '2014-04-04T06:26:30.355Z', | ||
'startedAt' => '2014-04-04T06:26:30.860Z', | ||
'version' => '2014-04-04T06:26:23.051Z' | ||
}, | ||
] | ||
} | ||
end | ||
let(:expected_backend_hash) do | ||
{ | ||
'name' => 'agouti.local', 'host' => 'agouti.local', 'port' => 31336 | ||
} | ||
end | ||
|
||
it 'adds the task as a backend' do | ||
expect(subject).to receive(:set_backends).with([expected_backend_hash]) | ||
subject.start | ||
end | ||
|
||
context 'with a custom port_index' do | ||
let(:config) do | ||
super().tap do |c| | ||
c['discovery']['port_index'] = 1 | ||
end | ||
end | ||
|
||
let(:expected_backend_hash) do | ||
{ | ||
'name' => 'agouti.local', 'host' => 'agouti.local', 'port' => 31337 | ||
} | ||
end | ||
|
||
it 'adds the task as a backend' do | ||
expect(subject).to receive(:set_backends).with([expected_backend_hash]) | ||
subject.start | ||
end | ||
|
||
context 'when that port_index does not exist' do | ||
let(:config) do | ||
super().tap { |c| c['discovery']['port_index'] = 999 } | ||
end | ||
|
||
it 'does not include the backend' do | ||
expect(subject).to receive(:set_backends).with([]) | ||
subject.start | ||
end | ||
end | ||
end | ||
|
||
context 'with a task that has not started yet' do | ||
let(:marathon_response) do | ||
super().tap do |resp| | ||
resp['tasks'] << { | ||
'host' => 'agouti.local', | ||
'id' => 'my-app_2-1396592790353', | ||
'ports' => [ | ||
31336, | ||
31337 | ||
], | ||
'stagedAt' => '2014-04-04T06:26:30.355Z', | ||
'startedAt' => nil, | ||
'version' => '2014-04-04T06:26:23.051Z' | ||
} | ||
end | ||
end | ||
|
||
it 'filters tasks that have no startedAt value' do | ||
expect(subject).to receive(:set_backends).with([expected_backend_hash]) | ||
subject.start | ||
end | ||
end | ||
|
||
context 'when marathon returns invalid response' do | ||
let(:marathon_response) { [] } | ||
it 'does not blow up' do | ||
expect { subject.start }.to_not raise_error | ||
end | ||
end | ||
|
||
context 'when the job takes a long time for some reason' do | ||
let(:job_duration) { 10 } # seconds | ||
|
||
before do | ||
actual_time = Time.now | ||
time_offset = -1 * job_duration | ||
allow(Time).to receive(:now) do | ||
# on first run, return the right time | ||
# subsequently, add in our job_duration offset | ||
actual_time + (time_offset += job_duration) | ||
end | ||
allow(subject).to receive(:set_backends) | ||
end | ||
|
||
it 'only sleeps for the difference' do | ||
expect(subject).to receive(:sleep).with(check_interval - job_duration) | ||
subject.start | ||
end | ||
end | ||
end | ||
end | ||
end | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to set @connection to nil here to ensure we attempt to connect back to marathon?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think so.