ruby_skynet 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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