chain-reactor 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ module ChainReactor
2
+
3
+ # Raised if there's an error running a reaction.
4
+ #
5
+ # This is a simple wrapper around the originally raised exception.
6
+ class ReactionError < StandardError
7
+ attr_accessor :original_exception
8
+ def initialize(original_exception)
9
+ @original_exception = original_exception
10
+ super(original_exception.message)
11
+ end
12
+ end
13
+
14
+ # Represents a single reaction block, defined in the chain file with the 'react_to' method.
15
+ class Reaction
16
+
17
+ require 'parser_factory'
18
+
19
+ # The previous return value from this reaction, if executed before.
20
+ attr_accessor :previous_result
21
+ # The previous data set sent to this reaction, if executed before.
22
+ attr_accessor :previous_data
23
+ # Options used by this reaction.
24
+ attr_accessor :options
25
+
26
+ # Create a new reaction, with the options and code block to run.
27
+ def initialize(options = {},block,logger)
28
+ @options = { :parser => :json, :required_keys => [], :keys_to_sym => true }.merge(options)
29
+ @block = block
30
+ @log = logger
31
+ @log.debug { "Created reaction with options: #{options}" }
32
+ end
33
+
34
+ # Executes the block of code after parsing the data string.
35
+ def execute(data_string)
36
+ parser = ParserFactory.get_parser(@options[:parser],@log)
37
+ data = parser.parse(data_string,@options[:required_keys],@options[:keys_to_sym])
38
+ begin
39
+ @previous_result = @block.call(data)
40
+ @previous_data = data
41
+ rescue StandardError => e
42
+ raise ReactionError.new(e)
43
+ end
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,69 @@
1
+ module ChainReactor
2
+ require 'reaction'
3
+
4
+ # Contains the map of reactions and allowed client addresses.
5
+ #
6
+ # This is used to determine which clients are allowed to connect, and to
7
+ # dispatch reactions.
8
+ class Reactor
9
+
10
+ # Pass a logger object and define an empty address map.
11
+ def initialize(logger)
12
+ @address_map = {}
13
+ @log = logger
14
+ end
15
+
16
+ # Add a react_to block from the chain file.
17
+ #
18
+ # Creates a Reaction object and calls add_address().
19
+ def add(addresses,options,block)
20
+ reaction = Reaction.new(options,block,@log)
21
+ addresses.each { |addr| add_address(addr,reaction) }
22
+ end
23
+
24
+ # Add an address with a reaction.
25
+ def add_address(address,reaction)
26
+ @log.debug { "Linking reaction to address #{address}" }
27
+
28
+ if @address_map.has_key? address
29
+ @address_map[address] << reaction
30
+ else
31
+ @address_map[address] = [reaction]
32
+ end
33
+ end
34
+
35
+ # React to a client by running the associated reactions.
36
+ #
37
+ # Raises an error if the address is not allowed - use
38
+ # address_allowed? first.
39
+ def react(address,data_string)
40
+ address_allowed?(address) or raise 'Address is not allowed'
41
+ @address_map[address].each do |reaction|
42
+ @log.info { "Executing reaction for address #{address}" }
43
+ begin
44
+ reaction.execute(data_string)
45
+ rescue Parsers::ParseError => e
46
+ @log.error { "Parser error: #{e.message}" }
47
+ rescue Parsers::RequiredKeyError => e
48
+ @log.error { "Client data invalid: #{e.message}" }
49
+ rescue ReactionError => e
50
+ @log.error { 'Exception raised in reaction: '+e.message }
51
+ end
52
+ end
53
+ end
54
+
55
+ # Get an array of reactions for a given address
56
+ def reactions_for(address)
57
+ @address_map[address] if address_allowed?(address)
58
+ end
59
+
60
+ # Check whether the IP address is allowed.
61
+ #
62
+ # An IP address is allowed if the chain file specifies a "react_to" block
63
+ # with that address.
64
+ def address_allowed?(address)
65
+ @address_map.has_key? address
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,94 @@
1
+ module ChainReactor
2
+
3
+ require 'socket'
4
+ require 'parsers/parser'
5
+ require 'client_connection'
6
+
7
+ # Creates a server socket and listens for client connections on an infinite
8
+ # loop. Each connecting client creates a new thread, allowing multiple
9
+ # connections at the same time.
10
+ class Server
11
+
12
+ # Create a new <tt>TCPServer</tt> listening on the given port.
13
+ def initialize(address,port,reactor,logger)
14
+ @server = TCPServer.open(address,port)
15
+ @reactor = reactor
16
+ @log = logger
17
+ @log.info { "Started ChainReactor v#{VERSION} server on #{address}:#{port.to_s}" }
18
+ end
19
+
20
+ # Start the server listening on an infinite loop.
21
+ #
22
+ # The multithreaded option allows each client connection to be opened
23
+ # in a new thread.
24
+ #
25
+ # Keyboard interrupts are caught and handled gracefully.
26
+ def start(multithreaded)
27
+ if multithreaded
28
+ Thread.abort_on_exception = true
29
+ @log.info "Accepting concurrent connections with new threads"
30
+ end
31
+ begin
32
+ loop do
33
+ if multithreaded
34
+ Thread.new(@server.accept) do |client_sock|
35
+ handle_sock(client_sock)
36
+ end
37
+ else
38
+ handle_sock(@server.accept)
39
+ end
40
+ end
41
+ rescue Interrupt, SystemExit => e
42
+ @server.close
43
+ @log.info { "Shutting down the ChainReactor server" }
44
+ raise e
45
+ rescue Exception => e
46
+ @server.close
47
+ raise e
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # Create a new ClientConnection when a socket is opened.
54
+ def handle_sock(client_sock)
55
+ begin
56
+ client = ClientConnection.new(client_sock,@log)
57
+ handle_client(client)
58
+ rescue ClientConnectionError => e
59
+ @log.error { "Client error: #{e.message}" }
60
+ client.close
61
+ end
62
+ end
63
+
64
+ # Handle a single client connection.
65
+ #
66
+ # First, the IP address is checked to determine whether it's
67
+ # allowed to connect. Then the server sends a welcome
68
+ # message so the client knows what it's connected to. Finally,
69
+ # data is read from the client and a reaction is executed.
70
+ def handle_client(client)
71
+ unless @reactor.address_allowed? client.ip
72
+ client.close
73
+ @log.warn { "Terminated connection from unauthorized client #{client.ip}" }
74
+ return
75
+ end
76
+
77
+ client.say("ChainReactor v#{VERSION}")
78
+
79
+ client_string = ''
80
+ while l = client.read
81
+ client_string += l
82
+ end
83
+
84
+ @log.debug { "Read from client #{client.ip}: #{client_string}" }
85
+ @log.info { "Finished reading from client #{client.ip}, closing connection" }
86
+
87
+ client.close
88
+
89
+ @reactor.react(client.ip,client_string)
90
+
91
+ end
92
+
93
+ end
94
+ end
@@ -0,0 +1,5 @@
1
+ # The ChainReactor module encapsulates all classes in the gem.
2
+ module ChainReactor
3
+ # Current gem version.
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,7 @@
1
+ ##########################################
2
+ # Chainfile for integration tests #
3
+ ##########################################
4
+
5
+ react_to('127.0.0.1') do |data|
6
+ puts data.inspect
7
+ end
@@ -0,0 +1,9 @@
1
+
2
+ module ChainReactor::Parsers
3
+ class DummyParser < Parser
4
+ def do_parse(string)
5
+ {}
6
+ end
7
+ end
8
+ end
9
+
data/test/helpers.rb ADDED
@@ -0,0 +1,71 @@
1
+
2
+ module ChainReactor
3
+ module TestHelpers
4
+ class Logger
5
+ def debug
6
+ end
7
+ def info
8
+ end
9
+ def warning
10
+ end
11
+ def error
12
+ end
13
+ def fatal
14
+ end
15
+ end
16
+
17
+ class Params
18
+ def initialize(hash_data)
19
+ @hash = hash_data
20
+ end
21
+
22
+ def method_missing(name, *args, &block)
23
+ @hash.send(name, *args, &block)
24
+ end
25
+
26
+ def fetch(key,&block)
27
+ begin
28
+ @hash.fetch(key,&block)
29
+ rescue KeyError => e
30
+ raise ::Main::Parameter::NoneSuch, key
31
+ end
32
+ end
33
+
34
+ end
35
+
36
+ class CliParam
37
+ def initialize(value)
38
+ @value = value
39
+ @given = true
40
+ end
41
+
42
+ def value
43
+ @value
44
+ end
45
+
46
+ def given=(given)
47
+ @given = given
48
+ end
49
+
50
+ def given?
51
+ @given
52
+ end
53
+ end
54
+
55
+ class File
56
+ def initialize(string)
57
+ @string = string
58
+ end
59
+
60
+ def read
61
+ @string
62
+ end
63
+ end
64
+
65
+ def get_logger
66
+ Logger.new
67
+ end
68
+
69
+ end
70
+ end
71
+
@@ -0,0 +1,69 @@
1
+ require 'test/unit'
2
+ require 'rubygems'
3
+ require 'open3'
4
+
5
+ # Test case for the options of the chain reactor executable.
6
+ class TestChainReactorOptions < Test::Unit::TestCase
7
+
8
+ def exec_with(arg_string)
9
+ bin_path = File.dirname(__FILE__)+'/../bin/chain-reactor'
10
+ Open3.popen3("#{bin_path} #{arg_string}")
11
+ end
12
+
13
+ def test_help_returns_help_in_stdout
14
+ out = exec_with('--help')
15
+ help = out[1].read
16
+ status = out[3].value
17
+
18
+ assert_equal 1, status.exitstatus
19
+ assert_match(/NAME\s*chain\-reactor/, help)
20
+ assert_match(/SYNOPSIS\s*chain\-reactor \(start|stop|template\) chainfile\.rb/, help)
21
+ end
22
+
23
+ def test_no_args_returns_help_in_stdout_and_fails
24
+ out = exec_with('')
25
+ help = out[1].read
26
+ status = out[3].value
27
+
28
+ assert_equal 1, status.exitstatus
29
+ assert_match(/NAME\s*chain\-reactor/, help)
30
+ assert_match(/SYNOPSIS\s*chain\-reactor \(start|stop|template\) chainfile\.rb/, help)
31
+ end
32
+
33
+ def test_invalid_mode_returns_help_and_fails
34
+ out = exec_with('ssaduhdui')
35
+ help = out[1].read
36
+ status = out[3].value
37
+
38
+ assert_equal 1, status.exitstatus
39
+ assert_match(/NAME\s*chain\-reactor/, help)
40
+ assert_match(/SYNOPSIS\s*chain\-reactor \(start|stop|template\) chainfile\.rb/, help)
41
+ end
42
+
43
+ def test_template_returns_chainfile_in_stdout
44
+ out = exec_with('template')
45
+ template = out[1].read
46
+ status = out[3].value
47
+
48
+ assert_equal 0, status.exitstatus
49
+ assert_match(/react_to\(/, template)
50
+ end
51
+
52
+ def test_start_without_chainfile_fails
53
+ out = exec_with('start')
54
+ error = out[2].read
55
+ status = out[3].value
56
+
57
+ assert_equal 1, status.exitstatus
58
+ assert_match(/chain-reactor: A valid chainfile must be supplied/, error)
59
+ end
60
+
61
+ def test_stop_without_chainfile_fails
62
+ out = exec_with('stop')
63
+ error = out[2].read
64
+ status = out[3].value
65
+
66
+ assert_equal 1, status.exitstatus
67
+ assert_match(/chain-reactor: A valid chainfile must be supplied/, error)
68
+ end
69
+ end
@@ -0,0 +1,88 @@
1
+ require 'test/unit'
2
+ require 'rubygems'
3
+ require 'open3'
4
+ require 'sys/proctable'
5
+ require 'client'
6
+
7
+ # Test case for the options of the chain reactor executable.
8
+ class TestChainReactorStart < Test::Unit::TestCase
9
+ include Sys
10
+
11
+ def setup
12
+ @test_path = File.dirname(__FILE__)
13
+ @bin_path = @test_path + '/../bin/chain-reactor'
14
+ @chainfile_path = @test_path + '/chainfile.test'
15
+ @pid_path = @test_path + '/chain-reactor.pid'
16
+ end
17
+
18
+ def teardown
19
+ _,_,_,t = stop_chain_reactor
20
+ t.value
21
+
22
+ $stdout = STDOUT
23
+ end
24
+
25
+ def start_chain_reactor(arg_string)
26
+ Open3.popen3("#{@bin_path} start #{@chainfile_path} #{arg_string} --pidfile #{@pid_path}")
27
+ end
28
+
29
+ def stop_chain_reactor
30
+ Open3.popen3("#{@bin_path} stop #{@chainfile_path} --pidfile #{@pid_path}")
31
+ end
32
+
33
+ def test_start_daemon
34
+ _, stdout, _, thread = start_chain_reactor('')
35
+ output = stdout.read
36
+
37
+ assert_match(/Registered 1 reactions/,output)
38
+ assert_match(/Starting daemon, PID file => #{@pid_path}/,output)
39
+ assert_match(/Daemon has started successfully/,output)
40
+
41
+ # Thread stopped as it's daemonized
42
+ assert_equal true, thread.stop?
43
+
44
+ assert File.file? @pid_path
45
+ pid = File.new(@pid_path).read.to_i
46
+ prc = ProcTable.ps(pid)
47
+
48
+ assert_not_nil prc
49
+ assert_match(/chain\-reactor start/,prc.cmdline)
50
+ end
51
+
52
+ def test_start_daemon_and_communicate_with_client
53
+ _, stdout, _, _ = start_chain_reactor('')
54
+ output = stdout.read
55
+ assert_match(/Daemon has started successfully/,output)
56
+
57
+ assert_nothing_raised ChainReactor::ClientError do
58
+ $stdout = File.new('/dev/null','w')
59
+ client = ChainReactor::Client.new('127.0.0.1',20000)
60
+ client.send_as_json({:hello => :world})
61
+ end
62
+ end
63
+
64
+ def test_start_daemon_with_port_override_and_communicate_with_client
65
+ _, stdout, _, _ = start_chain_reactor('--port 20100')
66
+ output = stdout.read
67
+ assert_match(/Daemon has started successfully/,output)
68
+
69
+ assert_nothing_raised ChainReactor::ClientError do
70
+ $stdout = File.new('/dev/null','w')
71
+ client = ChainReactor::Client.new('127.0.0.1',20100)
72
+ client.send({:hello => :world})
73
+ end
74
+ end
75
+
76
+ def test_start_daemon_with_address_override_and_communicate_with_client
77
+ _, stdout, _, _ = start_chain_reactor('--address 0.0.0.0')
78
+ output = stdout.read
79
+ assert_match(/Daemon has started successfully/,output)
80
+
81
+ assert_nothing_raised ChainReactor::ClientError do
82
+ $stdout = File.new('/dev/null','w')
83
+ client = ChainReactor::Client.new('0.0.0.0',20000)
84
+ client.send({:hello => :world})
85
+ end
86
+ end
87
+ end
88
+