servicy 0.0.3

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.
data/lib/api.rb ADDED
@@ -0,0 +1,126 @@
1
+ # TODO: Investigate this kind of thing using Delegator to see if I can get
2
+ # a speed boost. Each exported method would have to be dynamically defined
3
+ # in the API class to do contract checking and then pass that on to the
4
+ # delegated object. I don't, however, know if that's actually faster than
5
+ # using method_missing. Investigate
6
+ module Servicy
7
+ class API
8
+ def self.create(klass, method_descriptions)
9
+ @klass = klass
10
+ method_descriptions = { class: method_descriptions[:class].inject({}) { |h, info| h[info[:method].to_sym] = info; h },
11
+ instance: method_descriptions[:instance].inject({}) { |h, info| h[info[:method].to_sym] = info; h }
12
+ }
13
+
14
+ # We inherit from API here so that in the future we can add functionality
15
+ # for setting up servers, service discovery, etc.
16
+ # This may change to Client at some point... Who
17
+ # knows.
18
+ magic_class = Class.new(Servicy::API) do
19
+ @@base_class = klass
20
+ @@method_descriptions = method_descriptions
21
+
22
+ def initialize(*args)
23
+ @instance = @@base_class.new(*args)
24
+ end
25
+
26
+ def method_descriptions
27
+ @@method_descriptions
28
+ end
29
+
30
+ # The magic happens in the two method_missing methods.
31
+ # Meta-programming-averse; gaze upon my works, ye haughty, and despair!
32
+ def method_missing(method, *args, &block)
33
+ # Verify the contract
34
+ data = method_descriptions[:instance][method]
35
+ raise NoMethodError.new("#{@klass.to_s} does not expose an API endpoint called, #{method.to_s}") unless data
36
+ data[:contract].valid_args?(*args)
37
+
38
+ # Dispatch the result
39
+ result = @instance.send(method, *args, &block)
40
+
41
+ # Check the result
42
+ data[:contract].valid_return?(*args, result)
43
+
44
+ # And we are good to go
45
+ result
46
+ end
47
+
48
+ # Make sure that we can respond to the things we actually do respond to.
49
+ def respond_to?(method)
50
+ method_descriptions[:instance].include?(method) || super
51
+ end
52
+
53
+ def self.method_missing(method, *args, &block)
54
+ # Verify the contract
55
+ data = @@method_descriptions[:class][method]
56
+ raise NoMethodError.new("#{@klass.to_s} does not expose an API endpoint called, #{method.to_s}") unless data
57
+ data[:contract].valid_args?(*args)
58
+
59
+ # Dispatch the result
60
+ result = @@base_class.send(method, *args, &block)
61
+
62
+ # Check the result
63
+ data[:contract].valid_return?(*args, result)
64
+
65
+ # And we are good to go
66
+ result
67
+ end
68
+
69
+ def self.respond_to?(method)
70
+ @@method_descriptions[:class].include?(method) || super
71
+ end
72
+
73
+ # Send back either all the documentation, or just for a particular
74
+ # method.
75
+ def self.docs(method=nil)
76
+ if method.nil?
77
+ return @@method_descriptions.map do |(type, methods)|
78
+ methods.values.map { |v| v[:docs] }
79
+ end.flatten
80
+ else
81
+ search_in = [:class, :instance]
82
+ if method.is_a?(String)
83
+ search_in = method[0] == '.' ? [:class] : (method[0] == '#' ? [:instance] : search_in)
84
+ end
85
+ search_in.each do |type|
86
+ @@method_descriptions[type].each do |(name,stuff)|
87
+ return stuff[:docs] if method.to_s =~ /(\.|#)?#{name.to_s}$/
88
+ end
89
+ end
90
+ return nil
91
+ end
92
+ end
93
+ end
94
+ magic_class.extend(ExtraMethods)
95
+ klass.constants(false).each do |const|
96
+ magic_class.const_set(const, klass.const_get(const))
97
+ end
98
+
99
+ # Give it a good name
100
+ class_name = "#{klass.to_s}API"
101
+ Object.const_set(class_name, magic_class)
102
+ end
103
+
104
+ # TODO: API wrappers that create a server of some kind to serve things up
105
+ # TODO: A gem/library generator based on the API
106
+ end
107
+
108
+ module ExtraMethods
109
+ # This method is used by the client discovery to build the query that can be
110
+ # used to find remote instances of this service
111
+ def search_query
112
+ domain = self.const_get(:DOMAIN) rescue `hostname`.strip
113
+ name = "#{domain}.#{self.to_s.downcase}"
114
+ version = self.const_get(:VERSION) rescue '1.0.0'
115
+ port = self.const_get(:PORT) rescue 1234
116
+ heartbeat_port = self.const_get(:HEARTBEAT_PORT) rescue port
117
+
118
+ { name: name, host: 'localhost', port: port, version: version, heartbeat_port: heartbeat_port }
119
+ end
120
+
121
+ # Sets the remote configuration options
122
+ def set_remote_configuration(config)
123
+ @remote_config = config
124
+ end
125
+ end
126
+ end
data/lib/client.rb ADDED
@@ -0,0 +1,114 @@
1
+ require 'contraction'
2
+ require 'api'
3
+
4
+ # The client will be responsible for connecting to servers to do service
5
+ # registration and discovery. I would like the registration to happen
6
+ # auto-magically via reflection and system inspection, but we'll see how that
7
+ # goes.
8
+ module Servicy
9
+ class Client
10
+ class ServiceNotFoundError < StandardError; end;
11
+ attr_reader :transport
12
+
13
+ # Create a new Client instance
14
+ def initialize(transport=nil)
15
+ @transport = transport || Transport.InMemory.new
16
+ end
17
+
18
+ # Register a service with the service discovery portal found at the other
19
+ # end of @transport
20
+ def register_service(args)
21
+ if args.is_a?(Hash)
22
+ @transport.send(Servicy::Transport::Message.registration(Servicy::Service.new(args)))
23
+ else
24
+ # TODO: Check if it's an API, and if so register with that.
25
+ end
26
+ end
27
+
28
+ # Find a single service matching a query. This will cause the server to use
29
+ # whatever configured load-balancing mechanism is configured for it.
30
+ def find_service(args)
31
+ service = @transport.send(Servicy::Transport::Message.query_one(args)).services.first
32
+ service && Servicy::Service.new(service)
33
+ end
34
+
35
+ # Find all services matching a query
36
+ def find_all_services(args)
37
+ @transport.send(Servicy::Transport::Message.query(args)).services.map { |s| Servicy::Service.new s }
38
+ end
39
+
40
+ # Send a message requesting server statistics
41
+ def server_stats
42
+ @transport.send(Servicy::Transport::Message.statistics)
43
+ end
44
+
45
+ # Creats an api from the provided class' public methods and returns it. You
46
+ # can then use said API to build a service that exports it over the wire
47
+ # how you would like.
48
+ def self.create_api_for(klass)
49
+ # Get all the public methods, and for each one get their arguments. Parse
50
+ # around the method to find documentation if any is available.
51
+ file_contents = {}
52
+ methods = klass.public_instance_methods - Object.instance_methods - Module.instance_methods
53
+ all_methods = Contraction.methods_for klass
54
+ data = all_methods.inject({}) do |h, (type, methods)|
55
+ h[type] = methods.map do |method|
56
+ file, line = read_file_for_method(klass, method, type)
57
+ # If `file` is nil, there is no source file. Either it's an IRB
58
+ # session, or a standard library class.
59
+ next file if file.nil?
60
+
61
+ # Walking backwards through the text, we look for RDoc comments
62
+ lines = file[0...line-1]
63
+ doc = []
64
+ lines.reverse.each do |line|
65
+ line.strip!
66
+ break unless line.start_with? '#'
67
+ doc << line
68
+ end
69
+ doc.reverse!
70
+ next if doc.empty?
71
+
72
+ contract = Contraction::Parser.parse(doc, klass, method, type)
73
+
74
+ { method: method.to_s, docs: doc.join("\n"), contract: contract }
75
+ end.compact
76
+ h
77
+ end
78
+
79
+ API.create klass, data
80
+ end
81
+
82
+ # Ask the server at the other end of transport for a provider for the
83
+ # provided API object, and configure the API object to use the remote
84
+ # service.
85
+ def self.find_service_provider_for(api, transport)
86
+ client = Client.new(transport)
87
+ service = client.find_service(api.search_query)
88
+ raise ServiceNotFoundError.new(api.search_query) unless service
89
+
90
+ api.set_remote_configuration(service.to_h)
91
+ api
92
+ end
93
+
94
+ private
95
+
96
+ # Get the contents of a file that contains a method, along with the line
97
+ # that the method appears on.
98
+ def self.read_file_for_method(klass, method_name, type)
99
+ file, line = nil, nil
100
+ if type == :class
101
+ file, line = klass.method(method_name).source_location
102
+ elsif type == :instance
103
+ file, line = klass.instance_method(method_name).source_location
104
+ else
105
+ raise ArgumentError.new("Unknown method type, #{type.inspect}")
106
+ end
107
+ return nil, nil unless file # For c extensions and built-ins, source_location returns nil for file.
108
+ filename = File.expand_path(file)
109
+ file_contents = File.read(filename).split("\n")
110
+ return [file_contents, line]
111
+ end
112
+ end
113
+ end
114
+
data/lib/hash.rb ADDED
@@ -0,0 +1,14 @@
1
+ class Hash
2
+ def contains_only?(*args)
3
+ (keys - args).empty?
4
+ end
5
+
6
+ alias_method :orig_acc, :[]
7
+ def [](k)
8
+ return self.orig_acc(k) if self.orig_acc(k)
9
+ if k.respond_to?(:to_sym)
10
+ return self.orig_acc(k.to_sym) if self.orig_acc(k.to_sym)
11
+ end
12
+ return self.orig_acc(k.to_s) if self.orig_acc(k.to_s)
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module Servicy
2
+ class RandomLoadBalancer < LoadBalancer
3
+ def initialize
4
+ super
5
+ @last_host_index = {}
6
+ end
7
+
8
+ def next_for_service(service_name)
9
+ hosts[service_name][rand(hosts[service_name].length)]
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ module Servicy
2
+ class RoundRobinLoadBalancer < LoadBalancer
3
+ def initialize
4
+ super
5
+ @last_host_index = {}
6
+ end
7
+
8
+ def next_for_service(service_name)
9
+ @last_host_index[service_name] ||= 0
10
+ service = hosts[service_name][@last_host_index[service_name]]
11
+ @last_host_index[service_name] += 1
12
+ if @last_host_index[service_name] >= hosts[service_name].length
13
+ @last_host_index[service_name] = 0
14
+ end
15
+ service
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ module Servicy
2
+ class LoadBalancer
3
+ attr_accessor :hosts
4
+
5
+ def initialize
6
+ @hosts = {}
7
+ end
8
+
9
+ def next(set)
10
+ return nil if set.empty?
11
+ hosts[set.first.name] ||= []
12
+ hosts[set.first.name] += set
13
+ hosts[set.first.name].uniq!
14
+ next_for_service(set.first.name)
15
+ end
16
+
17
+ def next_for_service(service_name)
18
+ raise 'Not implemented'
19
+ end
20
+ end
21
+ end
22
+
23
+ Dir[File.expand_path(File.join(File.dirname(__FILE__), 'load_balancer')) + "/*.rb"].each do |f|
24
+ require f.gsub(/.rb$/, '')
25
+ end
@@ -0,0 +1,222 @@
1
+ module Servicy
2
+ class Server
3
+ attr_reader :services, :deactivated_services
4
+ attr_accessor :load_balancer, :transport
5
+
6
+ # Create a new server that is used to register and find Services.
7
+ # @param [{Servicy::Transport}] transport The transport used to send and
8
+ # receive messages.
9
+ # TODO: I should really make this take an options hash, but that will
10
+ # require changing a lot of shit...
11
+ def initialize(transport=nil, load_balancer=nil, logger_stream=nil, config_file=nil)
12
+ @transport = transport || InMemoryTransport.new
13
+ @services = [] # All services
14
+ @deactivated_services = [] # Dead services
15
+ @load_balancer = load_balancer || RoundRobinLoadBalancer.new
16
+ logger_stream ||= File.open(File.join('/var', 'log', 'servicy_server.log'), 'a')
17
+ @logger = Logger.new(logger_stream)
18
+ @config_file = config_file || File.expand_path("~/.servicy.conf")
19
+ start_heartbeat_process
20
+ end
21
+
22
+ # Starts the {Servicy::Transport} listening (whatever that means for the
23
+ # implementation), gets messages, handles them, and sends replies.
24
+ # TODO: I think I might need to invert this so that it tells the transport
25
+ # to start, and passes a block to the start method that handles the
26
+ # messages... That way I can have the transport fork when a connection
27
+ # comes in, handle it, and clean up the fork when it's done, allowing
28
+ # everything to keep chugging along.
29
+ def go!
30
+ transport.start do |message|
31
+ @logger.info "Received message, #{message.inspect}"
32
+ last_service_count = @services.length
33
+
34
+ result = nil
35
+ if message.message == 'registration'
36
+ self.register message.service
37
+ result = Transport::Message.success
38
+ elsif message.message == 'query'
39
+ if message.only_one
40
+ temp = [self.find_one(message.query)].compact
41
+ else
42
+ temp = self.find(message.query)
43
+ end
44
+ result = Transport::Message.services temp
45
+ elsif message.message == 'stats'
46
+ # Gather stats about the running system, and return it
47
+ # TODO: Other stats?
48
+ result = Transport::Message.statistics(num_services: last_service_count,
49
+ uptimes: uptimes,
50
+ latencies: latencies
51
+ )
52
+ else
53
+ result = Transport::Message.error("Bad message type, #{message.inspect}")
54
+ end
55
+
56
+ if @services.length > last_service_count
57
+ self.save!(@config_file)
58
+ last_service_count = @services.length
59
+ end
60
+ result
61
+ end
62
+
63
+ transport.stop
64
+ end
65
+
66
+ # Shut the server down
67
+ def stop!
68
+ transport.stop
69
+ @heartbeat_thread.kill
70
+ end
71
+
72
+ # Loads a server configuration off disk
73
+ # @param [String] filename The path of the file to load
74
+ # @return [Servicy::Server] configured server
75
+ def self.load(filename)
76
+ s = Servicy::Server.new
77
+ s.load!(filename)
78
+ s
79
+ end
80
+
81
+ # Register a new service
82
+ # @param [Hash{Symbol => String,Fixnum,Array<Hash>}] args The arguments for the
83
+ # service to register. This must include:
84
+ # :name
85
+ # :host
86
+ # :port
87
+ # You can also provide optional data,
88
+ # :version
89
+ # :heartbeat_port
90
+ # :protocol
91
+ # :api
92
+ # :name is the name of the service you are registering, in
93
+ # inverted-domain format (ie, "com.foo.api").
94
+ # :host must be either an IP address or hostname/domain name that is
95
+ # reachable by the Server instance (and any API consumers, of course.)
96
+ # :port must be an integer between 1 and 65535, as with :heartbeat_port,
97
+ # and is the port used to connect to the service provider.
98
+ # :heartbeat_port is the port to connect to to make sure that things are
99
+ # still running, and will default to the same as :port.
100
+ # :version is the version of the API/service that you are registering,
101
+ # and must be in the "major.minor.revision-patch" format, where the patch
102
+ # section is optional. For example, "1.0.3-p124". It defaults to,
103
+ # "1.0.0".
104
+ # :protocol can be anything you like, but is the protocol used to connect
105
+ # to the api. It defaults to "HTTP/S", meaning either HTTP or HTTP/TLS
106
+ # :api can be an array of hashes that describe the API provided. See the
107
+ # tests and README.md for examples.
108
+ def register(args)
109
+ service = Service.new(args)
110
+ @logger.info "Registering service, #{service.inspect}..."
111
+ if service.up?
112
+ @logger.info "Service, #{service}, up; registered."
113
+ @services << service
114
+ return service
115
+ else
116
+ @logger.info "Service, #{service}, down; not registered."
117
+ return false
118
+ end
119
+ end
120
+
121
+ # Un-register a previously registered service. This removes it from the
122
+ # active list of services that are available to searches, etc.
123
+ # @param [{Service}] service the service to de-register
124
+ def unregister(service)
125
+ @logger.info "De-registering service, #{service}"
126
+ @deactivated_services << service
127
+ end
128
+
129
+ # Removes a service from the deactivated list if it's there. No
130
+ # side-effects otherwise.
131
+ # @param [{Service}] service The service to remove from deactivated list
132
+ def reregister(service)
133
+ if @deactivated_services.delete(service)
134
+ @logger.info "Re-registering service, #{service}"
135
+ end
136
+ end
137
+
138
+ # Save the service configuration to disk
139
+ # @param [String] filename The path to where the config should be saved
140
+ def save!(filename)
141
+ File.open(filename, 'w') do |f|
142
+ f.write services.map(&:as_json).to_json
143
+ end
144
+ end
145
+
146
+ # Load a configuration from disk, and override the current one with it.
147
+ # @param [String] filename The path to the configuration file.
148
+ def load!(filename)
149
+ json = ''
150
+ File.open(filename, 'r') do |f|
151
+ json = f.read
152
+ end
153
+
154
+ @config_file = filename
155
+
156
+ @services = JSON.parse(json).map do |s|
157
+ s = s.inject({}) { |h,(k,v)| h[k.to_sym] = v; h }
158
+ Servicy::Service.new s
159
+ end
160
+ end
161
+
162
+ # Find one or more services based on some search criteria.
163
+ # TODO: Give more documentation about this here.
164
+ def find(args={})
165
+ @logger.info "Finding #{args.inspect}..."
166
+
167
+ searcher = Servicy::ServiceSearcher.new(services - deactivated_services)
168
+ searcher.filter_by_name args[:name]
169
+ searcher.filter_by_versions args[:min_version], args[:max_version], args[:version]
170
+ searcher.filter_by_api args[:api]
171
+
172
+ @logger.info "Found #{searcher.services.inspect}"
173
+ searcher.services
174
+ end
175
+
176
+ # (see #find)
177
+ # Return only the first load-balanced instance. If you want load-balancing,
178
+ # use this.
179
+ def find_one(args={})
180
+ load_balancer.next(find(args))
181
+ end
182
+
183
+ # Get a hash of uptimes for each service, where the key is the service name
184
+ # and hostname, joined with a '#', and the value is the uptime for that
185
+ # service on that host.
186
+ # @return [Hash{String => Float}] the uptimes of all services per host.
187
+ def uptimes
188
+ services.inject({}) { |h, s| h[s.to_s] = s.uptime; h }
189
+ end
190
+
191
+ # (see #uptimes)
192
+ # Hash is in the same format, but the values are avg latencies.
193
+ def latencies
194
+ services.inject({}) { |h, s| h[s.to_s] = s.avg_latency; h }
195
+ end
196
+
197
+ private
198
+
199
+ def start_heartbeat_process
200
+ @heartbeat_thread = Thread.new(self) do |server|
201
+ timer = 0
202
+ while true
203
+ server.services.each do |service|
204
+ time_to_beat = service.heartbeat_check_rate
205
+ time_to_beat *= 2 if server.deactivated_services.include?(service)
206
+ if timer - service.heartbeat_last_check >= time_to_beat
207
+ service.heartbeat_last_check = timer
208
+ if service.up?
209
+ server.reregister(service)
210
+ else
211
+ server.unregister(service)
212
+ end
213
+ end
214
+
215
+ timer += 1
216
+ sleep 1
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,115 @@
1
+ # The searcher is a class that handles all the search functionality for the server.
2
+ module Servicy
3
+ class ServiceSearcher
4
+ attr_reader :services
5
+
6
+ # Create a new searcher instance with the services that you want to filter
7
+ # down. To start with, we will just start it with all the server services,
8
+ # but as things get whiddled down, we will create more of these interfaces
9
+ # with new sets. It's a sort-of recursive class.
10
+ def initialize(services)
11
+ @services = services
12
+ end
13
+
14
+ # Filter based on the name of given, with some simple wild-carding.
15
+ def filter_by_name(name)
16
+ return unless name
17
+ @services = services.select { |s| name_matches_query?(s.name, name) }
18
+ end
19
+
20
+ # Filter down the services based on the version options. You can do min,
21
+ # max, by extension ranges, and exact matching.
22
+ def filter_by_versions(min=nil, max=nil, exact=nil)
23
+ if min
24
+ mv = Servicy::Service.version_as_number(min)
25
+ @services = services.select do |s|
26
+ s.version_as_number >= mv
27
+ end
28
+ end
29
+
30
+ if max
31
+ mv = Servicy::Service.version_as_number(max)
32
+ @services = services.select do |s|
33
+ s.version_as_number <= mv
34
+ end
35
+ end
36
+
37
+ if exact
38
+ mv = Servicy::Service.version_as_number(exact)
39
+ @services = services.select do |s|
40
+ s.version_as_number == mv
41
+ end
42
+ end
43
+ end
44
+
45
+ # To search by api you can define either class or instance methods by name
46
+ # with optional arity, arguments by name, type, or both, and return types.
47
+ # TODO: Support a simple query language ("#foo/-1[String]", etc)
48
+ def filter_by_api(api)
49
+ return unless api
50
+
51
+ new_set = []
52
+ api.each do |(type, methods)|
53
+ methods.each do |method|
54
+ services.each do |service|
55
+ if service.api && service.api[type]
56
+ service_api = service.api_for(type, method[:name])
57
+ next unless service_api
58
+ next unless check_arity(service_api, method)
59
+ next unless check_arguments(service_api, method)
60
+ next unless check_return_type(service_api, method)
61
+
62
+ new_set << service
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ @services = new_set
69
+ end
70
+
71
+ private
72
+
73
+ # Check simple wild-cards. If you end a query with a single dot, then you
74
+ # are looking for something that starts with the query, instead of a
75
+ # complete match.
76
+ # TODO: Support *, %, others (?)
77
+ def name_matches_query?(name, query)
78
+ if query[-1] == "."
79
+ name.start_with? query
80
+ else
81
+ name == query
82
+ end
83
+ end
84
+
85
+ # Check the return type against a method query
86
+ def check_return_type(service_api, method)
87
+ return true unless method[:return]
88
+ return true unless service_api[:return]
89
+ return false if method[:return][:type] != service_api[:return][:type]
90
+ true
91
+ end
92
+
93
+ # Check if the arguments given in an api query match the argument
94
+ # definition for a given method.
95
+ def check_arguments(service_api, method)
96
+ return true unless method[:args]
97
+ return true unless service_api[:args]
98
+
99
+ return false if method[:args].length != service_api[:args].length
100
+ names = service_api[:args].map { |a| a[:name] }
101
+ return false if method[:args].any? { |a| !names.include? a[:name] }
102
+ return false if method[:args].any? do |a|
103
+ named_arg = service_api[:args].select { |b| b[:name] == a[:name] }.first
104
+ a.include?(:required) && named_arg.include?(:required) && a[:required] != named_arg[:required]
105
+ end
106
+ true
107
+ end
108
+
109
+ # True if matching arity, false otherwise.
110
+ def check_arity(service_api, method)
111
+ return true unless method[:arity]
112
+ method[:arity] == (service_api[:args] || []).length
113
+ end
114
+ end
115
+ end
data/lib/server.rb ADDED
@@ -0,0 +1,8 @@
1
+ require 'service'
2
+ require 'json'
3
+ require 'transports'
4
+ require 'transport/in_memory_transport'
5
+ require 'load_balancer'
6
+ require 'logger'
7
+ require 'server/service_searcher'
8
+ require 'server/server'