ruby_skynet 0.1.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,293 @@
1
+ require 'bson'
2
+ require 'sync_attr'
3
+ require 'multi_json'
4
+
5
+ #
6
+ # RubySkynet Client
7
+ #
8
+ # Supports
9
+ # RPC calls to Skynet
10
+ # Skynet Service autodiscovery
11
+ #
12
+ module RubySkynet
13
+ class Client
14
+ include SyncAttr
15
+
16
+ # Default doozer configuration
17
+ # To replace this default, set the config as follows:
18
+ # RubySkynet::Client.doozer_config = { .... }
19
+ sync_attr_accessor :doozer_config do
20
+ {
21
+ :server => '127.0.0.1:8046',
22
+ :read_timeout => 5,
23
+ :connect_timeout => 3,
24
+ :connect_retry_interval => 0.1,
25
+ :connect_retry_count => 3
26
+ }
27
+ end
28
+
29
+ # Lazy initialize Doozer Client
30
+ sync_cattr_reader :doozer do
31
+ Doozer::Client.new
32
+ end
33
+
34
+ # Create a client connection, call the supplied block and close the connection on
35
+ # completion of the block
36
+ #
37
+ # Example
38
+ #
39
+ # require 'ruby_skynet'
40
+ # SemanticLogger.default_level = :trace
41
+ # SemanticLogger.appenders << SemanticLogger::Appender::File(STDOUT)
42
+ # RubySkynet::Client.connect('TutorialService') do |tutorial_service|
43
+ # p tutorial_service.call(:value => 5)
44
+ # end
45
+ def self.connect(service_name, params={})
46
+ begin
47
+ client = self.new(service_name, params)
48
+ yield(client)
49
+ ensure
50
+ client.close if client
51
+ end
52
+ end
53
+
54
+ # Returns a new RubySkynet Client for the named service
55
+ #
56
+ # Parameters:
57
+ # :service_name
58
+ # Name of the service to look for and connect to on Skynet
59
+ #
60
+ # :doozer_servers [Array of String]
61
+ # Array of URL's of doozer servers to connect to with port numbers
62
+ # ['server1:2000', 'server2:2000']
63
+ #
64
+ # The second server will only be attempted once the first server
65
+ # cannot be connected to or has timed out on connect
66
+ # A read failure or timeout will not result in switching to the second
67
+ # server, only a connection failure or during an automatic reconnect
68
+ #
69
+ # :read_timeout [Float]
70
+ # Time in seconds to timeout on read
71
+ # Can be overridden by supplying a timeout in the read call
72
+ # Default: 60
73
+ #
74
+ # :connect_timeout [Float]
75
+ # Time in seconds to timeout when trying to connect to the server
76
+ # Default: Half of the :read_timeout ( 30 seconds )
77
+ #
78
+ # :connect_retry_count [Fixnum]
79
+ # Number of times to retry connecting when a connection fails
80
+ # Default: 10
81
+ #
82
+ # :connect_retry_interval [Float]
83
+ # Number of seconds between connection retry attempts after the first failed attempt
84
+ # Default: 0.5
85
+ def initialize(service_name, params = {})
86
+ @service_name = service_name
87
+ @logger = SemanticLogger::Logger.new("#{self.class.name}: #{service_name}")
88
+
89
+ # User configurable options
90
+ params[:read_timeout] ||= 60
91
+ params[:connect_timeout] ||= 30
92
+ params[:connect_retry_interval] ||= 0.1
93
+ params[:connect_retry_count] ||= 5
94
+
95
+ # If Server name and port of where Skynet Service is running
96
+ # is not supplied look for it in Doozer
97
+ unless params[:server] || params[:servers]
98
+ params[:server] = self.class.server_for(service_name)
99
+ end
100
+
101
+ # Disable buffering the send since it is a RPC call
102
+ params[:buffered] = false
103
+
104
+ @logger.trace "Socket Connection parameters", params
105
+
106
+ # For each new connection perform the Skynet handshake
107
+ params[:on_connect] = Proc.new do |socket|
108
+ # Reset user_data on each connection
109
+ socket.user_data = 0
110
+
111
+ # Receive Service Handshake
112
+ # Registered bool
113
+ # Registered indicates the state of this service. If it is false, the connection will
114
+ # close immediately and the client should look elsewhere for this service.
115
+ #
116
+ # ClientID string
117
+ # ClientID is a UUID that is used by the client to identify itself in RPC requests.
118
+ @logger.debug "Waiting for Service Handshake"
119
+ service_handshake = self.class.read_bson_document(socket)
120
+ @logger.trace 'Service Handshake', service_handshake
121
+
122
+ # #TODO When a reconnect returns registered == false we need to go back to doozer
123
+ @registered = service_handshake['registered']
124
+ @client_id = service_handshake['clientid']
125
+
126
+ # Send blank ClientHandshake
127
+ client_handshake = { 'clientid' => @client_id }
128
+ @logger.debug "Sending Client Handshake"
129
+ @logger.trace 'Client Handshake', client_handshake
130
+ socket.send(BSON.serialize(client_handshake))
131
+ end
132
+
133
+ @socket = ResilientSocket::TCPClient.new(params)
134
+ end
135
+
136
+ # Performs a synchronous call to the Skynet Service
137
+ #
138
+ # Parameters:
139
+ # method_name [String|Symbol]:
140
+ # Name of the method to call at the service
141
+ # parameters [Hash]:
142
+ # Parameters to pass into the service
143
+ #
144
+ # Returns the Hash result returned from the Skynet Service
145
+ #
146
+ # Raises RubySkynet::ProtocolError
147
+ # Raises RubySkynet::SkynetException
148
+ def call(method_name, parameters)
149
+ # Skynet requires BSON RPC Calls to have the following format:
150
+ # https://github.com/bketelsen/skynet/blob/protocol/protocol.md
151
+ request_id = BSON::ObjectId.new.to_s
152
+ @logger.tagged request_id do
153
+ @logger.benchmark_info "Called Skynet Service: #{@service_name}.#{method_name}" do
154
+
155
+ # Resilient Send
156
+ retry_count = 0
157
+ @socket.retry_on_connection_failure do |socket|
158
+ # user_data is maintained per session and a different session could
159
+ # be supplied with each retry
160
+ socket.user_data ||= 0
161
+ header = {
162
+ 'servicemethod' => "#{@service_name}.Forward",
163
+ 'seq' => socket.user_data,
164
+ }
165
+ @logger.debug "Sending Header"
166
+ @logger.trace 'Header', header
167
+ socket.send(BSON.serialize(header))
168
+
169
+ @logger.trace 'Parameters:', parameters
170
+
171
+ # The parameters are placed in the request object in BSON serialized
172
+ # form
173
+ request = {
174
+ 'clientid' => @client_id,
175
+ 'in' => BSON.serialize(parameters).to_s,
176
+ 'method' => method_name.to_s,
177
+ 'requestinfo' => {
178
+ 'requestid' => request_id,
179
+ # Increment retry count to indicate that the request may have been tried previously
180
+ # TODO: this should be incremented if request is retried,
181
+ 'retrycount' => retry_count,
182
+ # TODO: this should be forwarded along in case of services also
183
+ # being a client and calling additional services. If empty it will
184
+ # be stuffed with connecting address
185
+ 'originaddress' => ''
186
+ }
187
+ }
188
+
189
+ @logger.debug "Sending Request"
190
+ @logger.trace 'Request', request
191
+ socket.send(BSON.serialize(request))
192
+ end
193
+
194
+ # Once send is successful it could have been processed, so we can no
195
+ # longer retry now otherwise we could create a duplicate
196
+ # retry_count += 1
197
+
198
+ # Read header first as a separate BSON document
199
+ @logger.debug "Reading header from server"
200
+ header = self.class.read_bson_document(@socket)
201
+ @logger.debug 'Header', header
202
+
203
+ # Read the BSON response document
204
+ @logger.debug "Reading response from server"
205
+ response = self.class.read_bson_document(@socket)
206
+ @logger.trace 'Response', response
207
+
208
+ # Ensure the sequence number in the response header matches the
209
+ # sequence number sent in the request
210
+ if seq_no = header['seq']
211
+ raise ProtocolError.new("Incorrect Response received, expected seq=#{@socket.user_data}, received: #{header.inspect}") if seq_no != @socket.user_data
212
+ else
213
+ raise ProtocolError.new("Invalid Response header, missing 'seq': #{header.inspect}")
214
+ end
215
+
216
+ # Increment Sequence number only on successful response
217
+ @socket.user_data += 1
218
+
219
+ # If an error is returned from Skynet raise a Skynet exception
220
+ if error = header['error']
221
+ raise SkynetException.new(error) if error.to_s.length > 0
222
+ end
223
+
224
+ # If an error is returned from the service raise a Service exception
225
+ if error = response['error']
226
+ raise ServiceException.new(error) if error.to_s.length > 0
227
+ end
228
+
229
+ # Return Value
230
+ # The return value is inside the response object, it's a byte array of it's own and needs to be deserialized
231
+ result = BSON.deserialize(response['out'])
232
+ @logger.trace 'Return Value', result
233
+ result
234
+ end
235
+ end
236
+ end
237
+
238
+ # Returns a BSON document read from the socket.
239
+ # Returns nil if the operation times out or if a network
240
+ # connection failure occurs
241
+ def self.read_bson_document(socket)
242
+ bytebuf = BSON::ByteBuffer.new
243
+ # Read 4 byte size of following BSON document
244
+ bytes = ''
245
+ socket.read(4, bytes)
246
+
247
+ # Read BSON document
248
+ sz = bytes.unpack("V")[0]
249
+ raise "Invalid Data received from server:#{bytes.inspect}" unless sz
250
+
251
+ bytebuf.append!(bytes)
252
+ bytes = ''
253
+ sz -= 4
254
+ until bytes.size >= sz
255
+ buf = ''
256
+ socket.read(sz, buf)
257
+ bytes << buf
258
+ end
259
+ bytebuf.append!(bytes)
260
+ return BSON.deserialize(bytebuf)
261
+ end
262
+
263
+ def close()
264
+ @socket.close
265
+ end
266
+
267
+ ##############################
268
+ #protected
269
+
270
+ # Returns [Array] of the hostname and port pair [String] that implements a particular service
271
+ # Performs a doozer lookup to find the servers
272
+ #
273
+ # service_name:
274
+ # version: Version of service to locate
275
+ # Default: Find latest version
276
+ def self.registered_implementers(service_name, version = '*', region = 'Development')
277
+ hosts = []
278
+ doozer.walk("/services/#{service_name}/#{version}/#{region}/*/*").each do |node|
279
+ entry = MultiJson.load(node.value)
280
+ hosts << entry if entry['Registered']
281
+ end
282
+ hosts
283
+ end
284
+
285
+ # Randomly returns a server that implements the requested service
286
+ def self.server_for(service_name, version = '*', region = 'Development')
287
+ hosts = registered_implementers(service_name, version, region)
288
+ service = hosts[rand(hosts.size)]['Config']['ServiceAddr']
289
+ "#{service['IPAddress']}:#{service['Port']}"
290
+ end
291
+
292
+ end
293
+ end
@@ -0,0 +1,202 @@
1
+ require 'ruby_skynet/doozer/msg.pb'
2
+ require 'semantic_logger'
3
+ require 'resilient_socket'
4
+ require 'ruby_skynet/doozer/exceptions'
5
+ require 'ruby_skynet/doozer/msg.pb'
6
+
7
+ module RubySkynet
8
+ module Doozer
9
+ class Client
10
+
11
+ # Create a resilient client connection to a Doozer server
12
+ def initialize(params={})
13
+ @logger = SemanticLogger::Logger.new(self.class)
14
+
15
+ # User configurable options
16
+ params[:read_timeout] ||= 5
17
+ params[:connect_timeout] ||= 3
18
+ params[:connect_retry_interval] ||= 0.1
19
+ params[:connect_retry_count] ||= 3
20
+
21
+ # Server name and port where Doozer is running
22
+ # Defaults to 127.0.0.1:8046
23
+ params[:server] ||= '127.0.0.1:8046' unless params[:servers]
24
+
25
+ # Disable buffering the send since it is a RPC call
26
+ params[:buffered] = false
27
+
28
+ @logger.trace "Socket Connection parameters", params
29
+
30
+ # For each new connection
31
+ params[:on_connect] = Proc.new do |socket|
32
+ # Reset user_data on each connection
33
+ socket.user_data = 0
34
+ end
35
+
36
+ @socket = ResilientSocket::TCPClient.new(params)
37
+ end
38
+
39
+ # Close this client connection to doozer
40
+ def close
41
+ @socket.close if @socket
42
+ end
43
+
44
+ # Returns the current Doozer revision
45
+ def current_revision
46
+ invoke(Request.new(:verb => Request::Verb::REV)).rev
47
+ end
48
+
49
+ # Set a value in Doozer
50
+ # path: Path to the value to be set
51
+ # value: Value to set
52
+ # rev: Revision at which to set the value
53
+ # If not supplied it will replace the latest version on the server
54
+ #
55
+ # Returns the new revision of the updated value
56
+ #
57
+ # It is recommended to set the revision so that multiple clients do not
58
+ # attempt to update the value at the same time.
59
+ # Setting the revision also allows the call to be retried automatically
60
+ # in the event of a network failure
61
+ def set(path, value, rev=-1)
62
+ invoke(Request.new(:path => path, :value => value, :rev => rev, :verb => Request::Verb::SET), false).rev
63
+ end
64
+
65
+ # Sets the current value at the supplied path
66
+ def []=(path,value)
67
+ set(path, value)
68
+ end
69
+
70
+ # Return the value at the supplied path and revision
71
+ def get(path, rev = nil)
72
+ invoke(Request.new(:path => path, :rev => rev, :verb => Request::Verb::GET))
73
+ end
74
+
75
+ # Returns just the value at the supplied path, not the revision
76
+ def [](path)
77
+ get(path).value
78
+ end
79
+
80
+ # Deletes the file at path if rev is greater than or equal to the file's revision.
81
+ # Returns nil when the file was removed
82
+ # Raises an exception if an attempt to remove the file and its revision
83
+ # is greater than that supplied
84
+ def delete(path, rev=-1)
85
+ invoke(Request.new(:path => path, :rev => rev, :verb => Request::Verb::DEL))
86
+ nil
87
+ end
88
+
89
+ # Returns the directory in the supplied path
90
+ # Use offset to get the next
91
+ # returns nil if no further paths are available
92
+ def directory(path, offset = 0, rev = nil)
93
+ begin
94
+ invoke(Request.new(:path => path, :rev => rev, :offset => offset, :verb => Request::Verb::GETDIR))
95
+ rescue RubySkynet::Doozer::ResponseError => exc
96
+ raise exc unless exc.message.include?('RANGE')
97
+ nil
98
+ end
99
+ end
100
+
101
+ def stat(path, rev = nil)
102
+ invoke(Request.new(:path => path, :rev => rev, :verb => Request::Verb::STAT))
103
+ end
104
+
105
+ def access(secret)
106
+ invoke(Request.new(:path => secret, :verb => Request::Verb::ACCESS))
107
+ end
108
+
109
+ # Returns every entry in the supplied path
110
+ # path can also contain wildcard characters such as '*'
111
+ # Example:
112
+ # hosts = []
113
+ # walk('/ctl/node/*/addr', current_revision).each do |node|
114
+ # hosts << node.value unless hosts.include? node.value
115
+ # end
116
+ def walk(path, rev = nil, offset = 0)
117
+ paths = []
118
+ revision = rev || current_revision
119
+ # Resume walk on network connection failure
120
+ @socket.retry_on_connection_failure do
121
+ while true
122
+ send(Request.new(:path => path, :rev => revision , :offset => offset, :verb => Request::Verb::WALK))
123
+ response = read
124
+ if response.err_code
125
+ break if response.err_code == Response::Err::RANGE
126
+ else
127
+ raise ResponseError.new("#{Response::Err.name_by_value(response.err_code)}: #{response.err_detail}") if response.err_code != 0
128
+ end
129
+ paths << response
130
+ offset += 1
131
+ end
132
+ end
133
+ paths
134
+ end
135
+
136
+ # Returns [Array] of hostname [String] with each string
137
+ # representing another Doozer server that can be connected to
138
+ def doozer_hosts
139
+ hosts = []
140
+ walk('/ctl/node/*/addr', current_revision).each do |node|
141
+ hosts << node.value unless hosts.include? node.value
142
+ end
143
+ end
144
+
145
+ # TODO Implement watching for changes in a separate thread with it's own
146
+ # client connection
147
+ #def watch(path, rev = nil)
148
+ # invoke(Request.new(:path => secret, :verb => Request::Verb::WAIT))
149
+ #end
150
+
151
+ #####################
152
+ # protected
153
+
154
+ # Call the Doozer server
155
+ #
156
+ # When readonly ==> true the request is always retried on network failure
157
+ # When readonly ==> false the request is retried on network failure
158
+ # _only_ if a rev has been supplied
159
+ #
160
+ # When modifier is true
161
+ def invoke(request, readonly=true)
162
+ retry_read = readonly || !request.rev.nil?
163
+ response = nil
164
+ @socket.retry_on_connection_failure do
165
+ send(request)
166
+ response = read if retry_read
167
+ end
168
+ # Network error on read must be sent back to caller since we do not
169
+ # know if the modification was made
170
+ response = read unless retry_read
171
+ raise ResponseError.new("#{Response::Err.name_by_value(response.err_code)}: #{response.err_detail}") if response.err_code != 0
172
+ response
173
+ end
174
+
175
+ # Send the protobuf Request to Doozer
176
+ def send(request)
177
+ request.tag = 0
178
+ data = request.serialize_to_string
179
+ # An additional header is added to the request indicating the size of the request
180
+ head = [data.length].pack("N")
181
+ @socket.send(head+data)
182
+ end
183
+
184
+ # Read the protobuf Response from Doozer
185
+ def read
186
+ # First strip the additional header indicating the size of the subsequent response
187
+ head = @socket.read(4)
188
+ length = head.unpack("N")[0]
189
+
190
+ # Since can returns upto 'length' bytes we need to make sure it returns
191
+ # at least 'length' bytes
192
+ # TODO: Make this a binary buffer
193
+ data = ''
194
+ until data.size >= length
195
+ data << @socket.read(length)
196
+ end
197
+ Response.new.parse_from_string(data)
198
+ end
199
+
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,5 @@
1
+ module RubySkynet
2
+ module Doozer
3
+ class ResponseError < ::RuntimeError; end
4
+ end
5
+ end
@@ -0,0 +1,118 @@
1
+ ### Generated by rprotoc. DO NOT EDIT!
2
+ ### <proto file: doozerd/server/msg.proto>
3
+ # package server;
4
+ #
5
+ # // see doc/proto.md
6
+ # message Request {
7
+ # optional int32 tag = 1;
8
+ #
9
+ # enum Verb {
10
+ # GET = 1;
11
+ # SET = 2;
12
+ # DEL = 3;
13
+ # REV = 5;
14
+ # WAIT = 6;
15
+ # NOP = 7;
16
+ # WALK = 9;
17
+ # GETDIR = 14;
18
+ # STAT = 16;
19
+ # ACCESS = 99;
20
+ # }
21
+ # optional Verb verb = 2;
22
+ #
23
+ # optional string path = 4;
24
+ # optional bytes value = 5;
25
+ # optional int32 other_tag = 6;
26
+ #
27
+ # optional int32 offset = 7;
28
+ #
29
+ # optional int64 rev = 9;
30
+ # }
31
+ #
32
+ # // see doc/proto.md
33
+ # message Response {
34
+ # optional int32 tag = 1;
35
+ # optional int32 flags = 2;
36
+ #
37
+ # optional int64 rev = 3;
38
+ # optional string path = 5;
39
+ # optional bytes value = 6;
40
+ # optional int32 len = 8;
41
+ #
42
+ # enum Err {
43
+ # // don't use value 0
44
+ # OTHER = 127;
45
+ # TAG_IN_USE = 1;
46
+ # UNKNOWN_VERB = 2;
47
+ # READONLY = 3;
48
+ # TOO_LATE = 4;
49
+ # REV_MISMATCH = 5;
50
+ # BAD_PATH = 6;
51
+ # MISSING_ARG = 7;
52
+ # RANGE = 8;
53
+ # NOTDIR = 20;
54
+ # ISDIR = 21;
55
+ # NOENT = 22;
56
+ # }
57
+ # optional Err err_code = 100;
58
+ # optional string err_detail = 101;
59
+ # }
60
+
61
+ require 'protobuf/message/message'
62
+ require 'protobuf/message/enum'
63
+ require 'protobuf/message/service'
64
+ require 'protobuf/message/extend'
65
+
66
+ module RubySkynet
67
+ module Doozer
68
+ class Request < ::Protobuf::Message
69
+ defined_in __FILE__
70
+ optional :int32, :tag, 1
71
+ class Verb < ::Protobuf::Enum
72
+ defined_in __FILE__
73
+ GET = value(:GET, 1)
74
+ SET = value(:SET, 2)
75
+ DEL = value(:DEL, 3)
76
+ REV = value(:REV, 5)
77
+ WAIT = value(:WAIT, 6)
78
+ NOP = value(:NOP, 7)
79
+ WALK = value(:WALK, 9)
80
+ GETDIR = value(:GETDIR, 14)
81
+ STAT = value(:STAT, 16)
82
+ ACCESS = value(:ACCESS, 99)
83
+ end
84
+ optional :Verb, :verb, 2
85
+ optional :string, :path, 4
86
+ optional :bytes, :value, 5
87
+ optional :int32, :other_tag, 6
88
+ optional :int32, :offset, 7
89
+ optional :int64, :rev, 9
90
+ end
91
+ class Response < ::Protobuf::Message
92
+ defined_in __FILE__
93
+ optional :int32, :tag, 1
94
+ optional :int32, :flags, 2
95
+ optional :int64, :rev, 3
96
+ optional :string, :path, 5
97
+ optional :bytes, :value, 6
98
+ optional :int32, :len, 8
99
+ class Err < ::Protobuf::Enum
100
+ defined_in __FILE__
101
+ OTHER = value(:OTHER, 127)
102
+ TAG_IN_USE = value(:TAG_IN_USE, 1)
103
+ UNKNOWN_VERB = value(:UNKNOWN_VERB, 2)
104
+ READONLY = value(:READONLY, 3)
105
+ TOO_LATE = value(:TOO_LATE, 4)
106
+ REV_MISMATCH = value(:REV_MISMATCH, 5)
107
+ BAD_PATH = value(:BAD_PATH, 6)
108
+ MISSING_ARG = value(:MISSING_ARG, 7)
109
+ RANGE = value(:RANGE, 8)
110
+ NOTDIR = value(:NOTDIR, 20)
111
+ ISDIR = value(:ISDIR, 21)
112
+ NOENT = value(:NOENT, 22)
113
+ end
114
+ optional :Err, :err_code, 100
115
+ optional :string, :err_detail, 101
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,6 @@
1
+ module RubySkynet
2
+ class Exception < ::RuntimeError; end
3
+ class ProtocolError < Exception; end
4
+ class SkynetException < Exception; end
5
+ class ServiceException < Exception; end
6
+ end
@@ -0,0 +1,3 @@
1
+ module RubySkynet #:nodoc
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,11 @@
1
+ require 'semantic_logger'
2
+ require 'resilient_socket'
3
+
4
+ require 'ruby_skynet/exceptions'
5
+ require 'ruby_skynet/version'
6
+ module RubySkynet
7
+ module Doozer
8
+ autoload :Client, 'ruby_skynet/doozer/client'
9
+ end
10
+ autoload :Client, 'ruby_skynet/client'
11
+ end
File without changes
@@ -0,0 +1 @@
1
+ platform.active=Ruby
@@ -0,0 +1,4 @@
1
+ clean=Remove any temporary products.
2
+ clobber=Remove any generated file.
3
+ gem=Build gem
4
+ test=Run Test Suite
@@ -0,0 +1,6 @@
1
+ javac.classpath=
2
+ main.file=
3
+ platform.active=Ruby
4
+ source.encoding=UTF-8
5
+ src.lib.dir=lib
6
+ test.test.dir=test
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project xmlns="http://www.netbeans.org/ns/project/1">
3
+ <type>org.netbeans.modules.ruby.rubyproject</type>
4
+ <configuration>
5
+ <data xmlns="http://www.netbeans.org/ns/ruby-project/1">
6
+ <name>ruby_skynet</name>
7
+ <source-roots>
8
+ <root id="src.lib.dir" name="Source Files"/>
9
+ </source-roots>
10
+ <test-roots>
11
+ <root id="test.test.dir"/>
12
+ </test-roots>
13
+ </data>
14
+ </configuration>
15
+ </project>
data/skynet-0.1.0.gem ADDED
Binary file