├── .rspec ├── Makefile ├── lib ├── villein │ ├── version.rb │ ├── tags.rb │ ├── event.rb │ ├── client.rb │ └── agent.rb └── villein.rb ├── Gemfile ├── Rakefile ├── perf ├── README.md ├── perf2.rb └── perf.rb ├── .gitignore ├── misc └── villein-event-handler.rb ├── extconf.rb ├── .travis.yml ├── spec ├── spec_helper.rb ├── event_handler_spec.rb ├── tags_spec.rb ├── event_spec.rb ├── agent_spec.rb └── client_spec.rb ├── villein.gemspec ├── LICENSE.txt ├── README.md └── src └── villein-event-handler.c /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | @echo ok 3 | install: 4 | @echo ok 5 | -------------------------------------------------------------------------------- /lib/villein/version.rb: -------------------------------------------------------------------------------- 1 | module Villein 2 | VERSION = "0.5.2" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in villein.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/villein.rb: -------------------------------------------------------------------------------- 1 | require "villein/version" 2 | 3 | module Villein 4 | # Your code goes here... 5 | end 6 | 7 | require 'villein/client' 8 | require 'villein/agent' 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require 'rdoc/task' 4 | 5 | RDoc::Task.new do |rdoc| 6 | rdoc.main = "README.md" 7 | rdoc.rdoc_files.include("README.md", "lib/**/*.rb") 8 | rdoc.rdoc_dir = "#{__dir__}/doc" 9 | end 10 | -------------------------------------------------------------------------------- /perf/README.md: -------------------------------------------------------------------------------- 1 | ## villein: tiny script for performance measuring 2 | 3 | open 2 shells and run: 4 | 5 | ``` 6 | $ ruby perf.rb 7 | ``` 8 | 9 | ``` 10 | $ serf agent & 11 | $ serf join localhost:17946 12 | ``` 13 | 14 | then: 15 | 16 | ``` 17 | $ ruby perf2.rb 18 | ``` 19 | -------------------------------------------------------------------------------- /perf/perf2.rb: -------------------------------------------------------------------------------- 1 | i = 0 2 | loop do 3 | 100.times do 4 | spawn "serf", "event", "-coalesce=false", "foo", i.to_s, out: File::NULL, err: File::NULL 5 | spawn "serf", "event", "-coalesce=false", "bar", i.to_s, out: File::NULL, err: File::NULL 6 | p i 7 | i += 1 8 | end 9 | sleep 2 10 | Process.waitall 11 | end 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | *.dSYM 24 | misc/villein-event-handler 25 | -------------------------------------------------------------------------------- /perf/perf.rb: -------------------------------------------------------------------------------- 1 | require 'villein' 2 | 3 | agent = Villein::Agent.new(node: 'perf', rpc_addr: '127.0.0.1:17373', bind: "127.0.0.1:17946", parallel_events: !!ARGV[0]) 4 | 5 | agent.on_user_event do |event| 6 | p event 7 | end 8 | 9 | agent.respond('myquery') do |event| 10 | p event 11 | Time.now.to_s 12 | end 13 | 14 | agent.auto_stop 15 | agent.start! 16 | 17 | sleep 18 | -------------------------------------------------------------------------------- /misc/villein-event-handler.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | #require 'json' 3 | require 'socket' 4 | 5 | sock = TCPSocket.new(ARGV[0], ARGV[1].to_i) 6 | sock.set_encoding(Encoding::ASCII_8BIT) 7 | 8 | 9 | ENV.each do |name, value| 10 | next unless name.start_with?('SERF'.freeze) 11 | 12 | sock.write "#{name}=#{value}\0" 13 | end 14 | 15 | sock.write "\0" 16 | sock.write $stdin.read 17 | sock.close_write 18 | 19 | if ENV["SERF_EVENT"] == "query" 20 | $stdout.write sock.read 21 | end 22 | -------------------------------------------------------------------------------- /extconf.rb: -------------------------------------------------------------------------------- 1 | # This extconf doesn't configure Ruby C extension, just builds 2 | # misc/villein-event-handler which is out of ruby environment. 3 | # 4 | require 'rbconfig' 5 | require 'fileutils' 6 | 7 | exit if /mswin/ === RUBY_PLATFORM 8 | 9 | misc = File.join(File.dirname(__FILE__), 'misc') 10 | src = File.join(File.dirname(__FILE__), 'src') 11 | 12 | Kernel.exec RbConfig::CONFIG["CC"], 13 | "-Wall", 14 | "-o", File.join(misc, 'villein-event-handler'), 15 | File.join(src, 'villein-event-handler.c') 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | rvm: 4 | - "2.1.1" 5 | - "2.0.0" 6 | - "2.1.0" 7 | - "ruby-head" 8 | 9 | matrix: 10 | allow_failures: 11 | - rvm: 12 | - "2.1.0" 13 | - "ruby-head" 14 | fast_finish: true 15 | notifications: 16 | email: 17 | - travis-ci@sorah.jp 18 | before_script: 19 | - ruby extconf.rb 20 | - mkdir -p vendor/serf 21 | - curl -L -o vendor/serf/serf.zip https://dl.bintray.com/mitchellh/serf/0.6.0_linux_amd64.zip 22 | - unzip -d vendor/serf vendor/serf/serf.zip 23 | - export PATH=$PWD/vendor/serf:$PATH 24 | script: bundle exec rspec -fd ./spec 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | # 6 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 7 | RSpec.configure do |config| 8 | config.treat_symbols_as_metadata_keys_with_true_values = true 9 | config.run_all_when_everything_filtered = true 10 | config.filter_run :focus 11 | 12 | # Run specs in random order to surface order dependencies. If you find an 13 | # order dependency and want to debug it, you can fix the order by providing 14 | # the seed, which is printed after each run. 15 | # --seed 1234 16 | config.order = 'random' 17 | end 18 | -------------------------------------------------------------------------------- /villein.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'villein/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "villein" 8 | spec.version = Villein::VERSION 9 | spec.authors = ["Shota Fukumori (sora_h)"] 10 | spec.email = ["sorah@cookpad.com", "her@sorah.jp"] 11 | spec.summary = %q{Use `serf` (serfdom.io) from Ruby.} 12 | spec.description = %q{Use serf (http://www.serfdom.io/) from Ruby.} 13 | spec.homepage = "" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | spec.extensions = ["extconf.rb"] 21 | 22 | spec.add_development_dependency "rspec", "2.14.1" 23 | spec.add_development_dependency "bundler", "~> 1.5" 24 | spec.add_development_dependency "rake" 25 | spec.add_development_dependency "rdoc" 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Shota Fukumori (sora_h) 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/villein/tags.rb: -------------------------------------------------------------------------------- 1 | module Villein 2 | class Tags 3 | def initialize(client) # :nodoc: 4 | @client = client 5 | reload 6 | end 7 | 8 | ## 9 | # Set tag of the agent. 10 | # When a value is nil, this will remove tag of key. 11 | def []=(key, value) 12 | if value 13 | key = key.to_s 14 | value = value.to_s 15 | 16 | # Don't do anything if same with existing value 17 | return value if self[key] == value 18 | 19 | @client.set_tag(key, value) 20 | @tags[key] = value 21 | else 22 | self.delete key.to_s 23 | end 24 | end 25 | 26 | ## 27 | # Update multiple tags at once. 28 | # When a value is nil, this will remove tag with key of a value. 29 | def update(tags={}) 30 | tags.each do |key, val| 31 | self[key] = val 32 | end 33 | 34 | self 35 | end 36 | 37 | ## 38 | # Returns tag of the agent. 39 | # Note that this method is cached, you have to call +reload+ method to flush them. 40 | def [](key) 41 | @tags[key.to_s] 42 | end 43 | 44 | ## 45 | # Remove tag from the agent. 46 | def delete(key) 47 | key = key.to_s 48 | 49 | @client.delete_tag key 50 | @tags.delete key 51 | 52 | nil 53 | end 54 | 55 | def inspect 56 | "#" 57 | end 58 | 59 | ## 60 | # Returns +Hash+ of tags. 61 | def to_h 62 | # duping 63 | Hash[@tags.map{ |k,v| [k,v] }] 64 | end 65 | 66 | ## 67 | # Reload tags of the agent. 68 | def reload 69 | @tags = @client.get_tags 70 | self 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/event_handler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'socket' 3 | 4 | describe 'event handler' do 5 | %w(villein-event-handler villein-event-handler.rb).each do |handler_name| 6 | _handler = File.join(__dir__, '..', 'misc', handler_name) 7 | opts = File.exist?(_handler) ? {} : {pending: "#{handler_name} not exists"} 8 | describe handler_name, opts do 9 | let(:handler) { _handler } 10 | 11 | def run_handler(env: {'SERF_EVENT' => 'user', 'SERF_USER_EVENT' => 'test'}, input: nil, response: nil) 12 | serv = TCPServer.new('localhost', 0) 13 | sockout = nil 14 | serv_thread = Thread.new do 15 | begin 16 | sock = serv.accept 17 | 18 | sockout = sock.read 19 | sock.write response if response 20 | sock.close_write 21 | ensure 22 | sock.close if sock && !sock.closed? 23 | serv.close if serv && !serv.closed? 24 | end 25 | end 26 | serv_thread.abort_on_exception = true 27 | 28 | stdout = nil 29 | IO.popen([env, handler, serv.addr[-1].to_s, serv.addr[1].to_s], 'r+') do |io| 30 | io.write(input) if input 31 | io.close_write 32 | stdout = io.read 33 | Process.waitpid2 io.pid 34 | end 35 | 36 | serv_thread.join 37 | return stdout, sockout 38 | end 39 | 40 | it "reports environment variables" do 41 | stdout, sockout = run_handler 42 | expect(sockout).to include("SERF_EVENT=user\0") 43 | expect(sockout).to include("SERF_USER_EVENT=test\0") 44 | expect(sockout).to match(/(user|test)\0\0\z/) 45 | end 46 | 47 | it "pass stdin to socket" do 48 | stdout, sockout = run_handler(input: "foo\n") 49 | expect(sockout).to include("SERF_EVENT=user\0") 50 | expect(sockout).to match(/(user|test)\0\0foo\n\z/m) 51 | end 52 | 53 | context "if SERF_EVENT=query" do 54 | it "pass socket input to stdout" do 55 | stdout, sockout = run_handler(env: {'SERF_EVENT' => 'query'}, input: "foo\n", response: "bar\n") 56 | expect(sockout).to match(/SERF_EVENT=query\0\0foo\n\z/m) 57 | expect(stdout).to eq("bar\n") 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/villein/event.rb: -------------------------------------------------------------------------------- 1 | module Villein 2 | class Event 3 | MEMBERS_EVENT = %w(member-join member-leave member-failed member-update member-reap) 4 | 5 | def initialize(env={}, payload: nil) 6 | @type = env['SERF_EVENT'] 7 | @self_name = env['SERF_SELF_NAME'] 8 | @self_tags = Hash[env.select{ |k, v| /^SERF_TAG_/ =~ k }.map { |k, v| [k.sub(/^SERF_TAG_/, ''), v] }] 9 | @user_event = env['SERF_USER_EVENT'] 10 | @query_name = env['SERF_QUERY_NAME'] 11 | @user_ltime = env['SERF_USER_LTIME'] 12 | @query_ltime = env['SERF_QUERY_LTIME'] 13 | @payload = payload 14 | end 15 | 16 | attr_reader :type, :self_name, :self_tags, :user_event, :query_name, :user_ltime, :query_ltime, :payload 17 | 18 | def ltime 19 | user_ltime || query_ltime 20 | end 21 | 22 | ## 23 | # Parse and returns member list in Array when available. 24 | # Always return +nil+ if the event type is not +member-*+. 25 | def members 26 | return nil unless MEMBERS_EVENT.include?(type) 27 | @members ||= begin 28 | payload.each_line.map do |line| 29 | name, address, _, tags_str = line.chomp.split(/\t/) 30 | {name: name, address: address, tags: parse_tags(tags_str || '')} 31 | end 32 | end 33 | end 34 | 35 | private 36 | 37 | def parse_tags(str) 38 | # "aa=b=,,c=d,e=f,g,h,i=j" => {"aa"=>"b=,", "c"=>"d", "e"=>"f,g,h", "i"=>"j"} 39 | tokens = str.scan(/(.+?)([,=]|\z)/).flatten 40 | 41 | pairs = [] 42 | stack = [] 43 | 44 | while token = tokens.shift 45 | case token 46 | when "=" 47 | stack << token 48 | when "," 49 | stack << token 50 | 51 | if tokens.first != ',' && 2 <= stack.size 52 | pairs << stack.dup 53 | stack.clear 54 | end 55 | else 56 | stack << token 57 | end 58 | end 59 | pairs << stack.dup unless stack.empty? 60 | 61 | pairs = pairs.inject([]) { |r, pair| 62 | if !pair.find{ |_| _ == '='.freeze } && r.last 63 | r.last.push(*pair) 64 | r 65 | else 66 | r << pair 67 | end 68 | } 69 | pairs.each(&:pop) 70 | Hash[pairs.map{ |_| _.join.split(/=/,2) }] 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/tags_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'villein/tags' 3 | 4 | describe Villein::Tags do 5 | let(:parent) { double('parent', get_tags: {'a' => '1'}) } 6 | subject(:tags) { described_class.new(parent) } 7 | 8 | describe "#[]" do 9 | it "returns value for key in Symbol" do 10 | expect(tags[:a]).to eq '1' 11 | end 12 | 13 | it "returns value for key in String" do 14 | expect(tags['a']).to eq '1' 15 | end 16 | end 17 | 18 | describe "#[]=" do 19 | it "sets value for key in Symbol, using parent#set_tag" do 20 | expect(parent).to receive(:set_tag).with('b', "1").and_return('1') 21 | 22 | tags['b'] = 1 23 | 24 | expect(tags['b']).to eq '1' 25 | end 26 | 27 | it "sets value for key in String, using parent#set_tag" do 28 | expect(parent).to receive(:set_tag).with('b', "1").and_return('1') 29 | 30 | tags[:b] = 1 31 | 32 | expect(tags[:b]).to eq '1' 33 | end 34 | 35 | context "with nil" do 36 | it "deletes key" do 37 | expect(tags).to receive(:delete).with('b') 38 | tags[:b] = nil 39 | end 40 | end 41 | end 42 | 43 | describe "#update(hash)" do 44 | it "sets multiple tags at once" do 45 | expect(parent).to receive(:set_tag).with('a', '0').and_return('1') 46 | expect(parent).to receive(:set_tag).with('b', '1').and_return('2') 47 | 48 | tags.update(a: 0, b: 1) 49 | end 50 | 51 | context "with nil" do 52 | it "removes tags at once" do 53 | expect(parent).to receive(:delete_tag).with('a') 54 | expect(parent).to receive(:delete_tag).with('b') 55 | 56 | tags.update(a: nil, b: nil) 57 | end 58 | end 59 | end 60 | 61 | describe "#delete" do 62 | it "deletes key, using parent#delete_tag" do 63 | expect(parent).to receive(:delete_tag).with('a') 64 | tags.delete :a 65 | expect(tags[:a]).to be_nil 66 | end 67 | end 68 | 69 | describe "#to_h" do 70 | it "returns hash" do 71 | expect(tags.to_h).to eq('a' => '1') 72 | end 73 | end 74 | 75 | describe "#reload" do 76 | it "retrieves latest tag using parent#get_tags" do 77 | tags # init 78 | allow(parent).to receive(:get_tags).and_return('new' => 'tag') 79 | 80 | expect { 81 | tags.reload 82 | }.to change { tags['new'] } \ 83 | .from(nil).to('tag') 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/event_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'villein/event' 3 | 4 | describe Villein::Event do 5 | it "holds event variables" do 6 | event = Villein::Event.new( 7 | 'SERF_EVENT' => 'type', 8 | 'SERF_SELF_NAME' => 'self_name', 9 | 'SERF_TAG_key' => 'val', 10 | 'SERF_TAG_key2' => 'val2', 11 | 'SERF_USER_EVENT' => 'user_event', 12 | 'SERF_QUERY_NAME' => 'query_name', 13 | 'SERF_USER_LTIME' => 'user_ltime', 14 | 'SERF_QUERY_LTIME' => 'query_ltime', 15 | payload: 'payload', 16 | ) 17 | 18 | expect(event.type).to eq 'type' 19 | expect(event.self_name).to eq 'self_name' 20 | expect(event.self_tags).to eq('key' => 'val', 'key2' => 'val2') 21 | expect(event.user_event).to eq 'user_event' 22 | expect(event.query_name).to eq 'query_name' 23 | expect(event.user_ltime).to eq 'user_ltime' 24 | expect(event.query_ltime).to eq 'query_ltime' 25 | expect(event.payload).to eq 'payload' 26 | end 27 | 28 | describe "#ltime" do 29 | it "returns user_ltime or query_ltime" do 30 | expect(described_class.new('SERF_USER_LTIME' => nil, 'SERF_QUERY_LTIME' => '2').ltime).to eq '2' 31 | expect(described_class.new('SERF_USER_LTIME' => '1', 'SERF_QUERY_LTIME' => nil).ltime).to eq '1' 32 | expect(described_class.new('SERF_USER_LTIME' => '1', 'SERF_QUERY_LTIME' => '2').ltime).to eq '1' 33 | end 34 | end 35 | 36 | describe "#members" do 37 | context "when event is member-*" do 38 | let(:payload) { "the-node\tX.X.X.X\t\tkey=val,a=b\nanother-node\tY.Y.Y.Y\t\tkey=val,a=b\n" } 39 | subject(:event) { Villein::Event.new('SERF_EVENT' => 'member-join', payload: payload) } 40 | 41 | it "parses member list" do 42 | expect(event.members).to be_a_kind_of(Array) 43 | expect(event.members.size).to eq 2 44 | expect(event.members[0]).to eq(name: 'the-node', address: 'X.X.X.X', tags: {'key' => 'val', 'a' => 'b'}) 45 | expect(event.members[1]).to eq(name: 'another-node', address: 'Y.Y.Y.Y', tags: {'key' => 'val', 'a' => 'b'}) 46 | end 47 | 48 | context "with confused tags" do 49 | let(:payload) { "the-node\tX.X.X.X\t\taa=b=,,c=d,e=f,g,h,i=j\n" } 50 | 51 | it "parses greedily" do 52 | expect(event.members[0][:tags]).to eq("aa"=>"b=,", "c"=>"d", "e"=>"f,g,h", "i"=>"j") 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Villein - Use `serf` from Ruby 2 | 3 | Use [serf](https://www.serfdom.io/) from Ruby. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | gem 'villein' 10 | 11 | And then execute: 12 | 13 | $ bundle 14 | 15 | Or install it yourself as: 16 | 17 | $ gem install villein 18 | 19 | ## Requirements 20 | 21 | - Ruby 2.0.0+ 22 | - Serf 23 | - v0.6.0 or later is required to use `query` and `info` method 24 | 25 | ## Usage 26 | 27 | ### Use for existing `serf agent` 28 | 29 | ``` ruby 30 | require 'villein' 31 | 32 | # You have to tell RPC address and node name. 33 | client = Villein::Client.new('localhost:7373', name: 'testnode') 34 | ``` 35 | 36 | ### Start new `serf agent` then use 37 | 38 | ``` ruby 39 | require 'villein' 40 | 41 | client = Villein::Agent.new 42 | 43 | # Start the agent 44 | client.start! 45 | 46 | # Stop the agent 47 | client.stop! 48 | 49 | # Inspect the status 50 | p client.running? 51 | p client.stopped? 52 | 53 | # You can specify many options to Agent.new... 54 | # :node, :rpc_addr, :bind, :iface, :advertise, :discover, 55 | # :config_file, :config_dir, :discover, :join, :snapshot, :encrypt, :profile, 56 | # :protocol, :event_handlers, :replay, :tags, :tags_file, :log_level 57 | ``` 58 | 59 | ### join and leave 60 | 61 | ``` ruby 62 | client.join('x.x.x.x') 63 | client.join('x.x.x.x', replay: true) 64 | client.leave() 65 | client.force_leave('other-node') 66 | ``` 67 | 68 | ### Sending user events 69 | 70 | ``` ruby 71 | # Send user event 72 | client.event('my-event', 'payload') 73 | client.event('my-event', 'payload', coalesce: true) 74 | ``` 75 | 76 | ### Querying 77 | 78 | ``` ruby 79 | client.query('hey', '') #=> {"Acks"=>["XXX.local"], "Responses"=>{"XXX"=>"..."}} 80 | ``` 81 | 82 | ### Retrieve member list 83 | 84 | ``` ruby 85 | # Retrieve member list 86 | client.members 87 | # => 88 | # [ 89 | # { 90 | # "name"=>"testnode", "addr"=>"x.x.x.x:7946", "port"=>7946, 91 | # "tags"=>{}, "status"=>"alive", 92 | # "protocol"=>{"max"=>4, "min"=>2, "version"=>4} 93 | # } 94 | # ] 95 | 96 | # You can use some filters. 97 | # The filters will be passed `serf members` command directly, so be careful 98 | # to escape regexp-like strings! 99 | client.members(name: 'foo') 100 | client.members(status: 'alive') 101 | client.members(tags: {foo: 'bar'}) 102 | ``` 103 | 104 | ### (Agent only) hook events 105 | 106 | ``` ruby 107 | agent = Villein::Agent.new 108 | agent.start! 109 | 110 | agent.on_member_join do |event| 111 | p event # => # 112 | p event.type # => 'member-join' 113 | p event.self_name 114 | p event.self_tags # => {"TAG1" => 'value1'} 115 | p event.members # => [{name: "the-node", address:, tags: {tag1: 'value1'}}] 116 | p event.user_event # => 'user' 117 | p event.query_name 118 | p event.ltime 119 | p event.payload #=> "..." 120 | end 121 | 122 | agent.on_member_leave { |event| ... } 123 | agent.on_member_failed { |event| ... } 124 | agent.on_member_update { |event| ... } 125 | agent.on_member_reap { |event| ... } 126 | agent.on_user_event { |event| ... } 127 | agent.on_query { |event| ... } 128 | 129 | # Catch any events 130 | agent.on_event { |event| p event } 131 | 132 | # Catch the agent stop 133 | agent.on_event { |status| 134 | # `status` will be a Process::Status, on unexpectedly exits. 135 | p status 136 | } 137 | ``` 138 | 139 | ### (Agent only) Respond to query events 140 | 141 | ``` ruby 142 | agent = Villein::Agent.new 143 | agent.start! 144 | 145 | agent.respond("hey") { "hello" } 146 | ``` 147 | 148 | ``` 149 | $ serf query hey 150 | Query 'hey' dispatched 151 | Ack from 'XXX.local' 152 | Response from 'XXX.local': hello 153 | Total Acks: 1 154 | Total Responses: 1 155 | ``` 156 | 157 | ## Advanced 158 | 159 | TBD 160 | 161 | ### Specifying location of `serf` command 162 | 163 | ### Logging `serf agent` 164 | 165 | ## FAQ 166 | 167 | ### Why I have to tell node name? 168 | 169 | ## Contributing 170 | 171 | 1. Fork it ( https://github.com/sorah/villein/fork ) 172 | 2. Create your feature branch (`git checkout -b my-new-feature`) 173 | 3. Commit your changes (`git commit -am 'Add some feature'`) 174 | 4. Push to the branch (`git push origin my-new-feature`) 175 | 5. Create a new Pull Request 176 | -------------------------------------------------------------------------------- /spec/agent_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'villein/client' 3 | 4 | require 'villein/agent' 5 | 6 | describe Villein::Agent do 7 | it "inherits Villein::Client" do 8 | expect(described_class.ancestors).to include(Villein::Client) 9 | end 10 | 11 | let(:bind) { ENV["VILLEIN_TEST_BIND"] || "127.0.0.1:17946" } 12 | let(:rpc_addr) { ENV["VILLEIN_TEST_RPC_ADDR"] || "127.0.0.1:17373" } 13 | 14 | subject(:agent) { described_class.new(rpc_addr: rpc_addr, bind: bind) } 15 | 16 | before do 17 | @pids = [] 18 | allow(agent).to(receive(:spawn) { |*args| 19 | pid = Kernel.spawn(*args) 20 | @pids << pid 21 | pid 22 | }) 23 | end 24 | 25 | after do 26 | @pids.each do |pid| 27 | begin 28 | begin 29 | timeout(5) { Process.waitpid(pid) } 30 | rescue Timeout::Error 31 | Process.kill(:KILL, pid) 32 | end 33 | rescue Errno::ECHILD, Errno::ESRCH 34 | end 35 | end 36 | end 37 | 38 | it "can start and stop workers" do 39 | received = nil 40 | agent.on_stop { |arg| received = [true, arg] } 41 | 42 | agent.start! 43 | 44 | Thread.new { agent.wait_for_ready }.join(5) 45 | 46 | expect(agent.dead?).to be_false 47 | expect(agent.running?).to be_true 48 | expect(agent.started?).to be_true 49 | expect(agent.ready?).to be_true 50 | expect(agent.pid).to be_a(Fixnum) 51 | 52 | agent.stop! 53 | 54 | expect(agent.dead?).to be_false 55 | expect(agent.running?).to be_false 56 | expect(agent.started?).to be_false 57 | expect(agent.ready?).to be_false 58 | expect(agent.pid).to be_nil 59 | 60 | expect(received).to eq [true, nil] 61 | end 62 | 63 | it "can receive events" do 64 | received1, received2 = nil, nil 65 | 66 | agent.on_event { |e| received1 = e } 67 | agent.on_member_join { |e| received2 = e } 68 | 69 | agent.start! 70 | 20.times { break if received1; sleep 0.1 } 71 | agent.stop! 72 | 73 | expect(received1).to be_a(Villein::Event) 74 | expect(received2).to be_a(Villein::Event) 75 | expect(received1.type).to eq 'member-join' 76 | expect(received2.type).to eq 'member-join' 77 | end 78 | 79 | context "with parallel_events=true" do 80 | subject(:agent) { described_class.new(rpc_addr: rpc_addr, bind: bind, parallel_events: false) } 81 | 82 | it "can receive events" do 83 | received1, received2 = nil, nil 84 | 85 | agent.on_event { |e| received1 = e } 86 | agent.on_member_join { |e| received2 = e } 87 | 88 | agent.start! 89 | 20.times { break if received1; sleep 0.1 } 90 | agent.stop! 91 | 92 | expect(received1).to be_a(Villein::Event) 93 | expect(received2).to be_a(Villein::Event) 94 | expect(received1.type).to eq 'member-join' 95 | expect(received2.type).to eq 'member-join' 96 | end 97 | end 98 | 99 | it "can respond to queries" do 100 | agent.respond("hey") do |e| 101 | expect(e).to be_a(Villein::Event) 102 | "hello" 103 | end 104 | 105 | agent.start! 106 | Thread.new { agent.wait_for_ready }.join(5) 107 | response = agent.query("hey", '') 108 | agent.stop! 109 | 110 | expect(response["Responses"].values.first).to eq("hello") 111 | end 112 | 113 | context "with async_query=false" do 114 | subject(:agent) { described_class.new(rpc_addr: rpc_addr, bind: bind, async_query: false) } 115 | 116 | it "can respond to queries" do 117 | agent.respond("hey") do |e| 118 | expect(e).to be_a(Villein::Event) 119 | "hello" 120 | end 121 | 122 | agent.start! 123 | Thread.new { agent.wait_for_ready }.join(5) 124 | response = agent.query("hey", '') 125 | agent.stop! 126 | 127 | expect(response["Responses"].values.first).to eq("hello") 128 | end 129 | end 130 | 131 | it "can handle unexpected stop" do 132 | received = nil 133 | agent.on_stop { |status| received = status } 134 | 135 | agent.start! 136 | Process.kill(:KILL, agent.pid) 137 | 138 | 20.times { break if received; sleep 0.1 } 139 | expect(agent.dead?).to be_true 140 | expect(agent.running?).to be_false 141 | expect(agent.started?).to be_true 142 | expect(received).to be_a_kind_of(Process::Status) 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /src/villein-event-handler.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | 7 | #include 8 | 9 | #include 10 | #include 11 | 12 | #include 13 | #include 14 | 15 | #include 16 | #include 17 | #include 18 | #include 19 | 20 | #define BUFSIZE 2048 21 | 22 | typedef struct { 23 | const char *hostname; 24 | const char *port; 25 | struct addrinfo *host_head; 26 | int sock; 27 | } Mission; 28 | 29 | extern char **environ; 30 | 31 | void 32 | cleanup(Mission *mission) 33 | { 34 | if (mission->host_head != NULL) 35 | freeaddrinfo(mission->host_head); 36 | if (mission->sock != -1) 37 | close(mission->sock); 38 | } 39 | 40 | int 41 | is_query_p() 42 | { 43 | return getenv("SERF_EVENT") != NULL && strcmp(getenv("SERF_EVENT"), "query") == 0; 44 | } 45 | 46 | void 47 | enable_nonblock(Mission *mission, int fd) 48 | { 49 | errno = 0; 50 | unsigned int flags = fcntl(fd, F_GETFL); 51 | 52 | if (errno != 0) { 53 | perror("oops: F_GETCL"); 54 | exit(1); 55 | } 56 | 57 | if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) { 58 | perror("oops: F_SETCL"); 59 | exit(1); 60 | } 61 | } 62 | 63 | void 64 | connect_villein(Mission *mission) 65 | { 66 | int err; 67 | struct addrinfo hints, *host; 68 | 69 | memset(&hints, 0, sizeof(hints)); 70 | hints.ai_socktype = SOCK_STREAM; 71 | hints.ai_family = PF_UNSPEC; 72 | 73 | if ((err = getaddrinfo(mission->hostname, mission->port, &hints, &mission->host_head)) != 0) { 74 | fprintf(stderr, "oops: getaddrinfo(%s, %s) error: %s\n", mission->hostname, mission->port, gai_strerror(err)); 75 | exit(1); 76 | } 77 | 78 | for(host = mission->host_head; host != NULL; host = host->ai_next) { 79 | if ((mission->sock = socket(host->ai_family, host->ai_socktype, host->ai_protocol)) < 0) { 80 | continue; 81 | } 82 | 83 | if (connect(mission->sock, host->ai_addr, host->ai_addrlen) != 0) { 84 | close(mission->sock); 85 | continue; 86 | } 87 | 88 | break; 89 | } 90 | 91 | if (host == NULL) { 92 | fprintf(stderr, "failed to connect host"); 93 | cleanup(mission); 94 | exit(1); 95 | } 96 | } 97 | 98 | void 99 | report_environ(Mission *mission) 100 | { 101 | char **env = environ; 102 | 103 | while (*env) { 104 | write(mission->sock, *env, strlen(*env) + 1); /* \0 */ 105 | env++; 106 | } 107 | write(mission->sock, "\0", 1); 108 | } 109 | 110 | void 111 | copy_stream(Mission *mission, int from_fd, int to_fd) 112 | { 113 | int maxfd = from_fd < to_fd ? to_fd : from_fd; 114 | int retval; 115 | fd_set rfds; 116 | char *buf; 117 | buf = malloc(BUFSIZE); 118 | 119 | FD_ZERO(&rfds); 120 | FD_SET(from_fd, &rfds); 121 | 122 | struct stat from_fdstat; 123 | fstat(from_fd, &from_fdstat); 124 | 125 | 126 | while (1) { 127 | ssize_t count = 0; 128 | retval = select(maxfd + 1, &rfds, NULL, NULL, NULL); 129 | 130 | if (retval == -1) { 131 | perror("oops: copy select"); 132 | free(buf); 133 | cleanup(mission); exit(1); 134 | } 135 | 136 | errno = 0; 137 | 138 | if (S_ISSOCK(from_fdstat.st_mode)) { 139 | count = recv(from_fd, buf, BUFSIZE, 0); 140 | } 141 | else { 142 | count = read(from_fd, buf, BUFSIZE); 143 | } 144 | 145 | if (errno == EAGAIN || errno == EWOULDBLOCK) continue; 146 | if (errno != 0) { 147 | perror("oops: copy read"); 148 | free(buf); 149 | cleanup(mission); exit(1); 150 | } 151 | if (count == 0) break; 152 | 153 | write(to_fd, buf, count); 154 | } 155 | } 156 | 157 | int 158 | main(int argc, const char *argv[]) 159 | { 160 | if (argc < 3) { 161 | fprintf(stderr, "usage: $0 host port\n"); 162 | return 2; 163 | } 164 | 165 | Mission mission; 166 | memset(&mission, 0, sizeof(mission)); 167 | 168 | mission.hostname = argv[1]; 169 | mission.port = argv[2]; 170 | mission.sock = -1; 171 | 172 | connect_villein(&mission); 173 | report_environ(&mission); 174 | 175 | enable_nonblock(&mission, 0); 176 | copy_stream(&mission, 0, mission.sock); 177 | shutdown(mission.sock, 1); /* close_write */ 178 | 179 | if (is_query_p()) { 180 | enable_nonblock(&mission, mission.sock); 181 | copy_stream(&mission, mission.sock, 1); 182 | } 183 | 184 | cleanup(&mission); 185 | 186 | return 0; 187 | } 188 | -------------------------------------------------------------------------------- /lib/villein/client.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'villein/tags' 3 | 4 | module Villein 5 | ## 6 | # Villein::Client allows you to order existing serf agent. 7 | # You will need RPC address and agent name to command. 8 | class Client 9 | ## 10 | # for serf command failures 11 | class SerfError < Exception; end 12 | 13 | ## 14 | # Error for the given argument exceeds the limit of serf when setting tags and sending events. 15 | class LengthExceedsLimitError < SerfError; end 16 | 17 | ## 18 | # Error for connection failures 19 | class SerfConnectionError < SerfError; end 20 | 21 | ## 22 | # Error when an called serf command is not found. 23 | class SerfCommandNotFound < SerfError; end 24 | 25 | ## 26 | # Error when an operation is not supported by the current version. 27 | class InsufficientVersionError < SerfError; end 28 | 29 | def initialize(rpc_addr, name: nil, serf: 'serf', silence: true) 30 | @rpc_addr = rpc_addr 31 | @name = name 32 | @serf = serf 33 | @silence = true 34 | 35 | retrieve_name unless @name 36 | end 37 | 38 | def silence?() !!@silence; end 39 | attr_writer :silence 40 | 41 | attr_reader :name, :rpc_addr, :serf 42 | 43 | ## 44 | # Returns a result of `serf info`. 45 | # This may raise InsufficientVersionError when `serf info` is not supported. 46 | def info 47 | JSON.parse call_serf('info', '-format', 'json') 48 | rescue SerfCommandNotFound 49 | raise InsufficientVersionError, 'serf v0.6.0 or later is required to run `serf info`.' 50 | end 51 | 52 | def event(name, payload, coalesce: true) 53 | options = [] 54 | 55 | unless coalesce 56 | options << '-coalesce=false' 57 | end 58 | 59 | call_serf 'event', *options, name, payload 60 | end 61 | 62 | def query(name, payload, node: nil, tag: nil, timeout: nil, no_ack: false) 63 | # TODO: version check 64 | options = ['-format', 'json'] 65 | 66 | if node 67 | node = [node] unless node.respond_to?(:each) 68 | node.each do |n| 69 | options << "-node=#{n}" 70 | end 71 | end 72 | 73 | if tag 74 | tag = [tag] unless tag.respond_to?(:each) 75 | tag.each do |t| 76 | options << "-tag=#{t}" 77 | end 78 | end 79 | 80 | if timeout 81 | options << "-timeout=#{timeout}" 82 | end 83 | 84 | if no_ack 85 | options << "-no-ack" 86 | end 87 | 88 | out = call_serf('query', *options, name, payload) 89 | JSON.parse(out) 90 | end 91 | 92 | def join(addr, replay: false) 93 | options = [] 94 | 95 | if replay 96 | options << '-replay' 97 | end 98 | 99 | call_serf 'join', *options, addr 100 | end 101 | 102 | def leave 103 | call_serf 'leave' 104 | end 105 | 106 | def force_leave(node) 107 | call_serf 'force-leave', node 108 | end 109 | 110 | def members(status: nil, name: nil, tags: {}) 111 | options = ['-format', 'json'] 112 | 113 | options.push('-status', status.to_s) if status 114 | options.push('-name', name.to_s) if name 115 | 116 | tags.each do |tag, val| 117 | options.push('-tag', "#{tag}=#{val}") 118 | end 119 | 120 | json = call_serf('members', *options) 121 | response = JSON.parse(json) 122 | 123 | response["members"] 124 | end 125 | 126 | ## 127 | # Returns Villein::Tags object for the current agent. 128 | # Villein::Tags provides high-level API for tagging agents. 129 | def tags 130 | @tags ||= Tags.new(self) 131 | end 132 | 133 | ## 134 | # Get tag from the agent. 135 | # Using Villein::Client#tags method is recommended. It provides high-level API via +Villein::Tags+. 136 | def get_tags 137 | me = members(name: self.name)[0] 138 | me["tags"] 139 | end 140 | 141 | ## 142 | # Remove tag from the agent. 143 | # Using Villein::Client#tags method is recommended. It provides high-level API via +Villein::Tags+. 144 | def delete_tag(key) 145 | call_serf 'tags', '-delete', key 146 | end 147 | 148 | ## 149 | # Set tag to the agent. 150 | # Using Villein::Client#tags method is recommended. It provides high-level API via +Villein::Tags+. 151 | def set_tag(key, val) 152 | call_serf 'tags', '-set', "#{key}=#{val}" 153 | end 154 | 155 | private 156 | 157 | def retrieve_name 158 | @name = self.info["agent"]["name"] 159 | end 160 | 161 | def call_serf(cmd, *args) 162 | status, out = IO.popen([@serf, cmd, "-rpc-addr=#{rpc_addr}", *args, err: [:child, :out]], 'r') do |io| 163 | o = io.read 164 | _, s = Process.waitpid2(io.pid) 165 | [s, o] 166 | end 167 | 168 | unless status.success? 169 | case out 170 | when /^Error connecting to Serf agent:/ 171 | raise SerfConnectionError, out.chomp 172 | when /exceeds limit of \d+ bytes$/ 173 | raise LengthExceedsLimitError, out.chomp 174 | when /^Available commands are:/ 175 | raise SerfCommandNotFound 176 | else 177 | raise SerfError, out.chomp 178 | end 179 | end 180 | 181 | out 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'villein/client' 3 | 4 | describe Villein::Client do 5 | let(:name) { 'the-node' } 6 | subject(:client) { described_class.new('x.x.x.x:nnnn', name: name) } 7 | 8 | def expect_serf(cmd, *args, success: true, message: '') 9 | if [cmd, *args].compact.empty? 10 | expect(IO).to receive(:popen) \ 11 | .with(any_args) 12 | .and_yield(double('io', read: message, pid: 727272)) 13 | 14 | else 15 | expect(IO).to receive(:popen) \ 16 | .with(['serf', cmd, '-rpc-addr=x.x.x.x:nnnn', *args, 17 | err: [:child, :out]], 18 | 'r') \ 19 | .and_yield(double('io', read: message, pid: 727272)) 20 | end 21 | 22 | status = double('proc-status', pid: 727272, success?: success) 23 | 24 | allow(Process).to receive(:waitpid2).with(727272).and_return([727272, status]) 25 | end 26 | 27 | shared_examples "failure cases" do 28 | context "when command failed" do 29 | it "raises error" do 30 | expect_serf(nil, success: false, message: 'err') 31 | 32 | expect { 33 | subject 34 | }.to raise_error(Villein::Client::SerfError, 'err') 35 | end 36 | end 37 | 38 | context "when connection failed" do 39 | it "raises error" do 40 | expect_serf(nil, 41 | success: false, 42 | message: 'Error connecting to Serf agent: dial tcp x.x.x.x:nnnn: connection refused') 43 | 44 | expect { 45 | subject 46 | }.to raise_error( 47 | Villein::Client::SerfConnectionError, 48 | 'Error connecting to Serf agent: dial tcp x.x.x.x:nnnn: connection refused') 49 | end 50 | end 51 | end 52 | 53 | describe "#initialize" do 54 | context "without name" do 55 | subject(:client) { described_class.new('x.x.x.x:nnnn') } 56 | 57 | it "retrieves name using #info" do 58 | # we can't use allow(client) here because it calls #initialize! 59 | allow_any_instance_of(described_class).to receive(:info).and_return( 60 | "agent" => {"name" => "the-name"}, 61 | ) 62 | 63 | expect(client.name).to eq('the-name') 64 | end 65 | end 66 | end 67 | 68 | describe "#info" do 69 | let(:json) { (<<-EOJ).gsub(/\n|\s+/,'') } 70 | { 71 | "agent": { 72 | "name": "foo" 73 | }, 74 | "runtime": { 75 | "arch": "amd64", 76 | "cpu_count": "8", 77 | "goroutines": "22", 78 | "max_procs": "1", 79 | "os": "darwin", 80 | "version": "go1.2" 81 | }, 82 | "serf": { 83 | "event_queue": "0", 84 | "event_time": "1", 85 | "failed": "0", 86 | "intent_queue": "0", 87 | "left": "0", 88 | "member_time": "2", 89 | "members": "2", 90 | "query_queue": "0", 91 | "query_time": "3" 92 | }, 93 | "tags": { 94 | "thisis": "tag" 95 | } 96 | } 97 | EOJ 98 | 99 | subject(:info) { client.info } 100 | 101 | it "returns `serf info`" do 102 | expect_serf('info', '-format', 'json', message: json) 103 | 104 | expect(info).to eq(JSON.parse(json)) 105 | end 106 | 107 | context "when not available" do 108 | it "raises error" do 109 | expect_serf('info', '-format', 'json', message: 'Available commands are:', success: false) 110 | 111 | expect { info }.to raise_error(Villein::Client::InsufficientVersionError) 112 | end 113 | end 114 | 115 | include_examples "failure cases" 116 | end 117 | 118 | describe "#event" do 119 | subject { client.event('test', 'payload') } 120 | 121 | it "sends user event" do 122 | expect_serf('event', 'test', 'payload') 123 | 124 | subject 125 | end 126 | 127 | context "with coalesce=false" do 128 | subject { client.event('test', 'payload', coalesce: false) } 129 | 130 | it "sends user event with the option" do 131 | expect_serf('event', '-coalesce=false', 'test', 'payload') 132 | 133 | subject 134 | end 135 | end 136 | 137 | include_examples "failure cases" 138 | 139 | context "when length exceeds limit" do 140 | it "raises error" do 141 | expect_serf('event', 'test', 'payload', 142 | success: false, 143 | message: 'Error sending event: user event exceeds limit of 256 bytes') 144 | 145 | expect { 146 | subject 147 | }.to raise_error( 148 | Villein::Client::LengthExceedsLimitError, 149 | 'Error sending event: user event exceeds limit of 256 bytes') 150 | end 151 | end 152 | end 153 | 154 | describe "#query" do 155 | let(:json) { (<<-EOJ).gsub(/\n|\s+/,'') } 156 | {"Acks":["foo","bar"], "Responses":{"foo":"response"}} 157 | EOJ 158 | 159 | subject(:query) { client.query('test', 'payload') } 160 | 161 | it "sends query event" do 162 | expect_serf('query', '-format', 'json', 'test', 'payload', message: json) 163 | 164 | expect(query).to eq(JSON.parse(json)) 165 | end 166 | 167 | context "with node filter" do 168 | context "in String" do 169 | subject(:query) { client.query('test', 'payload', node: 'foo') } 170 | 171 | it "queries with -node" do 172 | expect_serf('query', '-format', 'json', '-node=foo', 'test', 'payload', message: json) 173 | query 174 | end 175 | end 176 | 177 | context "in Array" do 178 | subject(:query) { client.query('test', 'payload', node: %w(foo bar)) } 179 | 180 | it "queries with -node" do 181 | expect_serf('query', '-format', 'json', '-node=foo', '-node=bar', 'test', 'payload', message: json) 182 | query 183 | end 184 | end 185 | end 186 | 187 | context "with tag filter" do 188 | context "in String" do 189 | subject(:query) { client.query('test', 'payload', tag: 'foo') } 190 | 191 | it "queries with -tag" do 192 | expect_serf('query', '-format', 'json', '-tag=foo', 'test', 'payload', message: json) 193 | query 194 | end 195 | end 196 | 197 | context "in Array" do 198 | subject(:query) { client.query('test', 'payload', tag: %w(foo bar)) } 199 | 200 | it "queries with -tag" do 201 | expect_serf('query', '-format', 'json', '-tag=foo', '-tag=bar', 'test', 'payload', message: json) 202 | query 203 | end 204 | end 205 | end 206 | 207 | include_examples "failure cases" 208 | 209 | context "when length exceeds limit" do 210 | it "raises error" do 211 | expect_serf('query', '-format', 'json', 'test', 'payload', 212 | success: false, 213 | message: 'Error sending event: query exceeds limit of 1024 bytes') 214 | 215 | expect { 216 | subject 217 | }.to raise_error( 218 | Villein::Client::LengthExceedsLimitError, 219 | 'Error sending event: query exceeds limit of 1024 bytes') 220 | end 221 | end 222 | end 223 | 224 | describe "#join" do 225 | subject { client.join('y.y.y.y:nnnn') } 226 | 227 | it "attempts to join another node" do 228 | expect_serf('join', 'y.y.y.y:nnnn') 229 | 230 | subject 231 | end 232 | 233 | context "with replay=true" do 234 | subject { client.join('y.y.y.y:nnnn', replay: true) } 235 | 236 | it "attempts to join another node with replaying" do 237 | expect_serf('join', '-replay', 'y.y.y.y:nnnn') 238 | 239 | subject 240 | end 241 | end 242 | 243 | include_examples "failure cases" 244 | end 245 | 246 | describe "#leave" do 247 | subject { client.leave } 248 | 249 | it "attempts to leave from cluster" do 250 | expect_serf('leave') 251 | 252 | subject 253 | end 254 | 255 | include_examples "failure cases" 256 | end 257 | 258 | describe "#force_leave" do 259 | subject { client.force_leave('the-node') } 260 | 261 | it "attempts to remove member forcely" do 262 | expect_serf('force-leave', 'the-node') 263 | 264 | subject 265 | end 266 | 267 | include_examples "failure cases" 268 | end 269 | 270 | describe "#members" do 271 | let(:json) { (<<-EOJ).gsub(/\n|\s+/,'') } 272 | {"members":[{"name":"the-node","addr":"a.a.a.a:mmmm","port": 7948, 273 | "tags":{"key":"val"},"status":"alive","protocol":{"max":4,"min":2,"version":4}}]} 274 | EOJ 275 | 276 | subject(:members) { client.members } 277 | 278 | it "returns member list" do 279 | expect_serf('members', '-format', 'json', message: json) 280 | 281 | expect(members).to be_a_kind_of(Array) 282 | expect(members[0]["name"]).to eq "the-node" 283 | end 284 | 285 | context "with status filter" do 286 | it "returns member list" do 287 | expect_serf('members', '-format', 'json', '-status', 'alive', message: json) 288 | 289 | client.members(status: :alive) 290 | end 291 | end 292 | 293 | context "with name filter" do 294 | it "returns member list" do 295 | expect_serf('members', '-format', 'json', '-name', 'node', message: json) 296 | 297 | client.members(name: 'node') 298 | end 299 | end 300 | 301 | context "with tag filter" do 302 | it "returns member list" do 303 | expect_serf('members', *%w(-format json -tag a=1 -tag b=2), message: json) 304 | 305 | client.members(tags: {a: '1', b: '2'}) 306 | end 307 | end 308 | 309 | include_examples "failure cases" 310 | end 311 | 312 | describe "#tags" do 313 | before do 314 | allow(client).to receive(:get_tags).and_return('a' => 'b') 315 | end 316 | 317 | it "returns Villein::Tags" do 318 | expect(client.tags).to be_a(Villein::Tags) 319 | expect(client.tags['a']).to eq 'b' 320 | end 321 | 322 | it "memoizes" do 323 | expect(client.tags.__id__ == client.tags.__id__).to be_true 324 | end 325 | end 326 | 327 | describe "#set_tag" do 328 | subject { client.set_tag('newkey', 'newval') } 329 | 330 | it "sets tag" do 331 | expect_serf('tags', '-set', 'newkey=newval') 332 | 333 | subject 334 | end 335 | 336 | include_examples "failure cases" 337 | 338 | context "when length exceeds limit" do 339 | it "raises error" do 340 | expect_serf('tags', '-set', 'newkey=newval', 341 | success: false, 342 | message: 'Error setting tags: Encoded length of tags exceeds limit of 512 bytes') 343 | 344 | expect { 345 | client.set_tag('newkey', 'newval') 346 | }.to raise_error( 347 | Villein::Client::LengthExceedsLimitError, 348 | 'Error setting tags: Encoded length of tags exceeds limit of 512 bytes') 349 | end 350 | end 351 | end 352 | 353 | describe "#delete_tag" do 354 | subject { client.delete_tag('newkey') } 355 | 356 | it "deletes tag" do 357 | expect_serf('tags', '-delete', 'newkey') 358 | 359 | subject 360 | end 361 | 362 | include_examples "failure cases" 363 | end 364 | 365 | describe "#get_tags" do 366 | subject(:tags) { client.get_tags } 367 | 368 | it "retrieves using #member(name: )" do 369 | json = (<<-EOJ).gsub(/\n|\s+/,'') 370 | {"members":[{"name":"the-node","addr":"a.a.a.a:mmmm","port": 7948, 371 | "tags":{"key":"val"},"status":"alive","protocol":{"max":4,"min":2,"version":4}}]} 372 | EOJ 373 | 374 | expect_serf('members', *%w(-format json -name the-node), message: json) 375 | 376 | expect(tags).to be_a_kind_of(Hash) 377 | expect(tags['key']).to eq 'val' 378 | end 379 | 380 | context "without name" do 381 | let(:name) { nil } 382 | 383 | it "raises error" do 384 | expect { tags }.to raise_error 385 | end 386 | end 387 | 388 | include_examples "failure cases" 389 | end 390 | end 391 | 392 | -------------------------------------------------------------------------------- /lib/villein/agent.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'timeout' 3 | require 'thread' 4 | require 'villein/client' 5 | require 'villein/event' 6 | 7 | module Villein 8 | ## 9 | # Villein::Agent allows you to start new serf agent. 10 | # Use this when you need to start and manage the serf agents from ruby process. 11 | class Agent < Client 12 | class AlreadyStarted < Exception; end 13 | class NotRunning < Exception; end 14 | class ResponderExists < Exception; end 15 | 16 | EVENT_HANDLER_NATIVE = File.expand_path(File.join(__dir__, '..', '..', 'misc', 'villein-event-handler')) 17 | EVENT_HANDLER_RB = File.expand_path(File.join(__dir__, '..', '..', 'misc', 'villein-event-handler.rb')) 18 | 19 | EVENT_HANDLER = if File.exist?(EVENT_HANDLER_NATIVE) 20 | EVENT_HANDLER_NATIVE 21 | else 22 | EVENT_HANDLER_RB 23 | end 24 | def initialize(serf: 'serf', 25 | node: Socket.gethostname, 26 | rpc_addr: '127.0.0.1:7373', bind: nil, iface: nil, advertise: nil, 27 | config_file: nil, config_dir: nil, 28 | discover: false, join: nil, snapshot: nil, 29 | encrypt: nil, profile: nil, protocol: nil, 30 | event_handlers: [], replay: nil, 31 | tags: {}, tags_file: nil, 32 | async_query: true, 33 | parallel_events: false, 34 | log_level: :info, log: File::NULL, 35 | raise_error_on_event_listener_fail: false, 36 | villein_handler: EVENT_HANDLER) 37 | @serf = serf 38 | @name = node 39 | @rpc_addr = rpc_addr 40 | @bind, @iface, @advertise = bind, iface, advertise 41 | @config_file, @config_dir = config_file, config_dir 42 | @discover, @join, @snapshot = discover, join, snapshot 43 | @encrypt, @profile, @protocol = encrypt, profile, protocol 44 | @custom_event_handlers, @replay = event_handlers, replay 45 | @initial_tags, @tags_file = tags, tags_file 46 | @async_query, @parallel_events = async_query, parallel_events 47 | @log_level, @log = log_level, log 48 | @raise_error_on_event_listener_fail = raise_error_on_event_listener_fail 49 | @villein_handler = villein_handler 50 | 51 | @hooks = {} 52 | @responders = {} 53 | 54 | @pid, @exitstatus = nil, nil 55 | @pid_lock = Mutex.new 56 | end 57 | 58 | attr_reader :pid, :exitstatus 59 | 60 | ## 61 | # Returns true when the serf agent has started 62 | def started? 63 | !!@pid 64 | end 65 | 66 | ## 67 | # Returns true when the serf agent has started, but stopped for some reason. 68 | # Use Agent#exitstatus to get Process::Status object. 69 | def dead? 70 | !!@exitstatus 71 | end 72 | 73 | ## 74 | # Return true when the agent has received events at least once. 75 | # Useful to wait serf for ready to use. 76 | def ready? 77 | running? && @event_received 78 | end 79 | 80 | ## 81 | # Returns true when the serf agent is running (it has started and not dead yet). 82 | def running? 83 | started? && !dead? 84 | end 85 | 86 | # Start the serf agent. 87 | def start! 88 | @pid_lock.synchronize do 89 | raise AlreadyStarted if running? 90 | 91 | @event_received = false 92 | 93 | start_listening_events 94 | start_process 95 | start_watchdog 96 | end 97 | end 98 | 99 | ## 100 | # Stop the serf agent. 101 | # After +timeout_sec+ seconds elapsed, it will attempt to KILL if the agent is still running. 102 | def stop!(timeout_sec = 10) 103 | @pid_lock.synchronize do 104 | raise NotRunning unless running? 105 | 106 | Process.kill(:INT, @pid) 107 | 108 | stop_watchdog 109 | call_hooks 'stop', nil 110 | 111 | kill_process(timeout_sec) 112 | 113 | stop_listening_events 114 | 115 | @pid = nil 116 | end 117 | end 118 | 119 | ## 120 | # Blocks until #ready? become true. 121 | def wait_for_ready 122 | sleep 0.1 until ready? 123 | end 124 | 125 | ## 126 | # Add +at_exit+ hook to safely stop at exit of current ruby process. 127 | # Note that +Kernel#.at_exit+ hook won't run when Ruby has crashed. 128 | def auto_stop 129 | at_exit { self.stop! if self.running? } 130 | end 131 | 132 | %w(member_join member_leave member_failed member_update member_reap 133 | user query stop event).each do |event| 134 | 135 | define_method(:"on_#{event}") do |&block| 136 | add_hook(event, block) 137 | end 138 | end 139 | 140 | alias_method :on_user_event, :on_user 141 | 142 | ## 143 | # Command line arguments to start serf-agent. 144 | def command 145 | cmd = [@serf, 'agent'] 146 | 147 | cmd << ['-node', @name] if @name 148 | cmd << '-replay' if @replay 149 | cmd << '-discover' if @discover 150 | 151 | @initial_tags.each do |key, val| 152 | cmd << ['-tag', "#{key}=#{val}"] 153 | end 154 | 155 | cmd << [ 156 | '-event-handler', 157 | [@villein_handler, *event_listener_addr].join(' ') 158 | ] 159 | 160 | @custom_event_handlers.each do |handler| 161 | cmd << ['-event-handler', handler] 162 | end 163 | 164 | %w(bind iface advertise config-file config-dir 165 | encrypt join log-level profile protocol rpc-addr 166 | snapshot tags-file).each do |key| 167 | 168 | val = instance_variable_get("@#{key.gsub(/-/,'_')}") 169 | cmd << ["-#{key}", val] if val 170 | end 171 | 172 | cmd.flatten.map(&:to_s) 173 | end 174 | 175 | ## 176 | # Respond to query events. 177 | # Raises error when override is false and responder for given query name already exists. 178 | def respond(name, override: false, &block) 179 | name = name.to_s 180 | if !override && @responders[name] 181 | raise ResponderExists, "Responder for #{name} already exists. To force, pass `override: true`" 182 | end 183 | 184 | @responders[name] = block 185 | end 186 | 187 | private 188 | 189 | def start_process 190 | @exitstatus = nil 191 | 192 | actual = -> { @pid = spawn(*command, out: @log, err: @log) } 193 | 194 | if defined? Bundler 195 | Bundler.with_clean_env(&actual) 196 | else 197 | actual.call 198 | end 199 | end 200 | 201 | def kill_process(timeout_sec = 10) 202 | begin 203 | begin 204 | timeout(timeout_sec) { Process.waitpid(@pid) } 205 | rescue Timeout::Error 206 | Process.kill(:KILL, @pid) 207 | end 208 | rescue Errno::ECHILD 209 | end 210 | end 211 | 212 | def start_watchdog 213 | return if @watchdog && @watchdog.alive? 214 | 215 | @watchdog = Thread.new do 216 | pid, @exitstatus = Process.waitpid2(@pid) 217 | call_hooks(:stop, @exitstatus) 218 | end 219 | end 220 | 221 | def stop_watchdog 222 | @watchdog.kill if @watchdog && @watchdog.alive? 223 | end 224 | 225 | def event_listener_addr 226 | raise "event listener not started [BUG]" unless @event_listener_server 227 | 228 | addr = @event_listener_server.addr 229 | [addr[-1], addr[1]] 230 | end 231 | 232 | def start_listening_events 233 | return if @event_listener_thread 234 | 235 | @event_listener_server = TCPServer.new('localhost', 0) 236 | @event_queue = Queue.new 237 | @event_listener_thread = Thread.new do 238 | event_listener_loop 239 | end 240 | 241 | @event_dispatcher_thread = Thread.new do 242 | event_dispatcher_loop 243 | end 244 | 245 | @event_listener_thread.abort_on_exception = @raise_error_on_event_listener_fail 246 | @event_dispatcher_thread.abort_on_exception = @raise_error_on_event_listener_fail 247 | end 248 | 249 | def stop_listening_events 250 | if @event_listener_thread && @event_listener_thread.alive? 251 | @event_listener_thread.kill 252 | end 253 | 254 | if @event_listener_dispatcher && @event_listener_dispatcher.alive? 255 | @event_listener_thread.kill 256 | end 257 | 258 | if @event_listener_server && !@event_listener_server.closed? 259 | @event_listener_server.close 260 | end 261 | 262 | @event_listener_thread = nil 263 | @event_listener_dispatcher = nil 264 | @event_queue = nil 265 | @event_listener_server = nil 266 | end 267 | 268 | def event_listener_loop 269 | while sock = @event_listener_server.accept 270 | sock_delegated = false 271 | begin 272 | buf, obuf = "", "" 273 | loop do 274 | socks, _, _ = IO.select([sock], nil, nil, 5) 275 | break unless socks 276 | 277 | begin 278 | socks[0].read_nonblock(2048, obuf) 279 | rescue EOFError 280 | break 281 | end 282 | 283 | buf << obuf 284 | break if socks[0].eof? 285 | end 286 | event = parse_event(buf) 287 | next unless event 288 | 289 | # XXX: 290 | if event.type == 'query' 291 | if @async_query 292 | sock_delegated = true 293 | Thread.new(event, sock) do |ev, so| 294 | handle_query ev, so 295 | end 296 | else 297 | handle_query event, sock 298 | end 299 | else 300 | if @parallel_events 301 | Thread.new(event) do |ev| 302 | handle_event event 303 | end 304 | else 305 | @event_queue << event 306 | end 307 | end 308 | ensure 309 | sock.close if !sock_delegated && !sock.closed? 310 | end 311 | end 312 | end 313 | 314 | def event_dispatcher_loop 315 | while event = @event_queue.pop 316 | handle_event event 317 | end 318 | end 319 | 320 | def parse_event(payload) 321 | env_payload, input = payload.split(/\0\0/,2) # ['name=val', 'name=val', '', 'input'] 322 | return unless env_payload && input 323 | 324 | env = Hash[env_payload.split(/\0/).map do |line| 325 | line.split(/=/,2) 326 | end] 327 | 328 | Event.new(env, payload: input) 329 | rescue JSON::ParserError 330 | # do nothing 331 | return nil 332 | end 333 | 334 | def handle_event(event) 335 | @event_received = true 336 | call_hooks event.type.gsub(/-/, '_'), event 337 | call_hooks 'event', event 338 | rescue Exception => e 339 | $stderr.puts "Exception during handling event: #{event.inspect}" 340 | $stderr.puts "#{e.class}: #{e.message}" 341 | $stderr.puts e.backtrace.map { |_| _.prepend("\t") } 342 | end 343 | 344 | def handle_query(event, sock) 345 | @event_received = true 346 | call_hooks event.type.gsub(/-/, '_'), event 347 | call_hooks 'event', event 348 | 349 | if event.type == 'query' && @responders[event.query_name] 350 | sock.write(@responders[event.query_name].call(event)) 351 | end 352 | rescue Exception => e 353 | $stderr.puts "Exception during handling query: #{event.inspect}" 354 | $stderr.puts "#{e.class}: #{e.message}" 355 | $stderr.puts e.backtrace.map { |_| _.prepend("\t") } 356 | ensure 357 | sock.close unless sock.closed? 358 | end 359 | 360 | def hooks_for(name) 361 | @hooks[name.to_s] ||= [] 362 | end 363 | 364 | def call_hooks(name, *args) 365 | hooks_for(name).each do |hook| 366 | hook.call(*args) 367 | end 368 | nil 369 | end 370 | 371 | def add_hook(name, block) 372 | hooks_for(name) << block 373 | end 374 | end 375 | end 376 | 377 | --------------------------------------------------------------------------------