arborist 0.0.1.pre20160128152542 → 0.0.1.pre20160606141735

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data.tar.gz.sig +2 -0
  4. data/ChangeLog +426 -1
  5. data/Manifest.txt +17 -2
  6. data/Nodes.md +70 -0
  7. data/Protocol.md +68 -9
  8. data/README.md +3 -5
  9. data/Rakefile +4 -1
  10. data/TODO.md +52 -20
  11. data/lib/arborist.rb +19 -6
  12. data/lib/arborist/cli.rb +39 -25
  13. data/lib/arborist/client.rb +97 -4
  14. data/lib/arborist/command/client.rb +2 -1
  15. data/lib/arborist/command/start.rb +51 -5
  16. data/lib/arborist/dependency.rb +286 -0
  17. data/lib/arborist/event.rb +7 -2
  18. data/lib/arborist/event/{node_matching.rb → node.rb} +11 -5
  19. data/lib/arborist/event/node_acked.rb +5 -7
  20. data/lib/arborist/event/node_delta.rb +30 -3
  21. data/lib/arborist/event/node_disabled.rb +16 -0
  22. data/lib/arborist/event/node_down.rb +10 -0
  23. data/lib/arborist/event/node_quieted.rb +11 -0
  24. data/lib/arborist/event/node_unknown.rb +10 -0
  25. data/lib/arborist/event/node_up.rb +10 -0
  26. data/lib/arborist/event/node_update.rb +2 -11
  27. data/lib/arborist/event/sys_node_added.rb +10 -0
  28. data/lib/arborist/event/sys_node_removed.rb +10 -0
  29. data/lib/arborist/exceptions.rb +4 -0
  30. data/lib/arborist/manager.rb +188 -18
  31. data/lib/arborist/manager/event_publisher.rb +1 -1
  32. data/lib/arborist/manager/tree_api.rb +92 -13
  33. data/lib/arborist/mixins.rb +17 -0
  34. data/lib/arborist/monitor.rb +10 -1
  35. data/lib/arborist/monitor/socket.rb +123 -2
  36. data/lib/arborist/monitor_runner.rb +6 -5
  37. data/lib/arborist/node.rb +420 -94
  38. data/lib/arborist/node/ack.rb +72 -0
  39. data/lib/arborist/node/host.rb +43 -8
  40. data/lib/arborist/node/resource.rb +73 -0
  41. data/lib/arborist/node/root.rb +6 -0
  42. data/lib/arborist/node/service.rb +89 -22
  43. data/lib/arborist/observer.rb +1 -1
  44. data/lib/arborist/subscription.rb +11 -6
  45. data/spec/arborist/client_spec.rb +93 -5
  46. data/spec/arborist/dependency_spec.rb +375 -0
  47. data/spec/arborist/event/node_delta_spec.rb +66 -0
  48. data/spec/arborist/event/node_down_spec.rb +84 -0
  49. data/spec/arborist/event/node_spec.rb +59 -0
  50. data/spec/arborist/event/node_update_spec.rb +14 -3
  51. data/spec/arborist/event_spec.rb +3 -3
  52. data/spec/arborist/manager/tree_api_spec.rb +295 -3
  53. data/spec/arborist/manager_spec.rb +240 -57
  54. data/spec/arborist/monitor_spec.rb +26 -3
  55. data/spec/arborist/node/ack_spec.rb +74 -0
  56. data/spec/arborist/node/host_spec.rb +79 -0
  57. data/spec/arborist/node/resource_spec.rb +56 -0
  58. data/spec/arborist/node/service_spec.rb +68 -2
  59. data/spec/arborist/node_spec.rb +288 -11
  60. data/spec/arborist/subscription_spec.rb +23 -14
  61. data/spec/arborist_spec.rb +0 -4
  62. data/spec/data/observers/webservices.rb +10 -2
  63. data/spec/spec_helper.rb +8 -0
  64. metadata +58 -15
  65. metadata.gz.sig +0 -0
  66. data/LICENSE +0 -29
@@ -0,0 +1,72 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'arborist/node' unless defined?( Arborist::Node )
5
+ require 'arborist/mixins'
6
+
7
+
8
+ # The inner class for the 'ack' operational property
9
+ class Arborist::Node::Ack
10
+ extend Arborist::HashUtilities
11
+
12
+ ### Construct an instance from the values in the specified +hash+.
13
+ def self::from_hash( hash )
14
+ hash = symbolify_keys( hash )
15
+
16
+ message = hash.delete( :message ) or raise ArgumentError, "Missing required ACK message"
17
+ sender = hash.delete( :sender ) or raise ArgumentError, "Missing required ACK sender"
18
+
19
+ if hash[:time]
20
+ hash[:time] = Time.at( hash[:time] ) if hash[:time].is_a?( Numeric )
21
+ hash[:time] = Time.parse( hash[:time] ) unless hash[:time].is_a?( Time )
22
+ end
23
+
24
+ return new( message, sender, **hash )
25
+ end
26
+
27
+
28
+ ### Create a new acknowledgement
29
+ def initialize( message, sender, via: nil, time: nil )
30
+ time ||= Time.now
31
+
32
+ @message = message
33
+ @sender = sender
34
+ @via = via
35
+ @time = time.to_time
36
+ end
37
+
38
+ ##
39
+ # The object's message, :sender, :via, :time
40
+ attr_reader :message, :sender, :via, :time
41
+
42
+
43
+ ### Return a string description of the acknowledgement for logging and inspection.
44
+ def description
45
+ return "by %s%s -- %s" % [
46
+ self.sender,
47
+ self.via ? " via #{self.via}" : '',
48
+ self.message
49
+ ]
50
+ end
51
+
52
+
53
+ ### Return the Ack as a Hash.
54
+ def to_h
55
+ return {
56
+ message: self.message,
57
+ sender: self.sender,
58
+ via: self.via,
59
+ time: self.time.iso8601,
60
+ }
61
+ end
62
+
63
+
64
+ ### Returns true if the +other+ object is an Ack with the same values.
65
+ def ==( other )
66
+ return other.is_a?( self.class ) &&
67
+ self.to_h == other.to_h
68
+ end
69
+
70
+ end # class Arborist::Node::Ack
71
+
72
+
@@ -19,7 +19,7 @@ class Arborist::Node::Host < Arborist::Node
19
19
 
20
20
 
21
21
  ### Create a new Host node.
22
- def initialize( identifier, &block )
22
+ def initialize( identifier, attributes={}, &block )
23
23
  @addresses = []
24
24
  super
25
25
  end
@@ -34,6 +34,22 @@ class Arborist::Node::Host < Arborist::Node
34
34
  attr_reader :addresses
35
35
 
36
36
 
37
+ ### Set one or more node +attributes+. Supported attributes (in addition to
38
+ ### those supported by Node) are: +addresses+.
39
+ def modify( attributes )
40
+ attributes = stringify_keys( attributes )
41
+
42
+ super
43
+
44
+ if attributes['addresses']
45
+ self.addresses.clear
46
+ Array( attributes['addresses'] ).each do |addr|
47
+ self.address( addr )
48
+ end
49
+ end
50
+ end
51
+
52
+
37
53
  ### Return the host's operational attributes.
38
54
  def operational_values
39
55
  properties = super
@@ -42,7 +58,7 @@ class Arborist::Node::Host < Arborist::Node
42
58
 
43
59
 
44
60
  ### Set an IP address of the host.
45
- def address( new_address, options={} )
61
+ def address( new_address )
46
62
  self.log.debug "Adding address %p to %p" % [ new_address, self ]
47
63
  case new_address
48
64
  when IPAddr
@@ -72,16 +88,35 @@ class Arborist::Node::Host < Arborist::Node
72
88
  end
73
89
 
74
90
 
75
- ### Add a service to the host
76
- def service( name, options={}, &block )
77
- return Arborist::Node.create( :service, name, self, options, &block )
78
- end
79
-
80
-
81
91
  ### Return host-node-specific information for #inspect.
82
92
  def node_description
83
93
  return "{no addresses}" if self.addresses.empty?
84
94
  return "{addresses: %s}" % [ self.addresses.map(&:to_s).join(', ') ]
85
95
  end
86
96
 
97
+
98
+ #
99
+ # Serialization
100
+ #
101
+
102
+ ### Return a Hash of the host node's state.
103
+ def to_h
104
+ return super.merge( addresses: self.addresses.map(&:to_s) )
105
+ end
106
+
107
+
108
+ ### Marshal API -- set up the object's state using the +hash+ from a previously-marshalled
109
+ ### node. Overridden to turn the addresses back into IPAddr objects.
110
+ def marshal_load( hash )
111
+ super
112
+ @addresses = hash[:addresses].map {|addr| IPAddr.new(addr) }
113
+ end
114
+
115
+
116
+ ### Equality operator -- returns +true+ if +other_node+ is equal to the
117
+ ### receiver. Overridden to also compare addresses.
118
+ def ==( other_host )
119
+ return super && other_host.addresses == self.addresses
120
+ end
121
+
87
122
  end # class Arborist::Node::Host
@@ -0,0 +1,73 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'arborist/node'
5
+ require 'arborist/mixins'
6
+
7
+
8
+ # A node type for Arborist trees that represent arbitrary resources of a host.
9
+ class Arborist::Node::Resource < Arborist::Node
10
+
11
+ # Services live under Host nodes
12
+ parent_type :host
13
+
14
+
15
+ ### Create a new Resource node.
16
+ def initialize( identifier, host, attributes={}, &block )
17
+ raise Arborist::NodeError, "no host given" unless host.is_a?( Arborist::Node::Host )
18
+ qualified_identifier = "%s-%s" % [ host.identifier, identifier ]
19
+
20
+ @host = host
21
+
22
+ super( qualified_identifier, host, attributes, &block )
23
+ end
24
+
25
+
26
+ ### Returns +true+ if the node matches the specified +key+ and +val+ criteria.
27
+ def match_criteria?( key, val )
28
+ self.log.debug "Matching %p: %p against %p" % [ key, val, self ]
29
+ return case key
30
+ when 'address'
31
+ search_addr = IPAddr.new( val )
32
+ self.addresses.any? {|a| search_addr.include?(a) }
33
+ else
34
+ super
35
+ end
36
+ end
37
+
38
+
39
+ ### Return a Hash of the operational values that are included with the node's
40
+ ### monitor state.
41
+ def operational_values
42
+ return super.merge(
43
+ addresses: self.addresses.map( &:to_s )
44
+ )
45
+ end
46
+
47
+
48
+ ### Delegate the resources's address to its host.
49
+ def addresses
50
+ return @host.addresses
51
+ end
52
+
53
+
54
+ ### Overridden to disallow modification of a Resource parent, as it needs a
55
+ ### reference to the Host node for delegation.
56
+ def parent( new_parent=nil )
57
+ return super unless new_parent
58
+ raise "Can't reparent a resource; replace the node instead"
59
+ end
60
+
61
+
62
+ #
63
+ # Serialization
64
+ #
65
+
66
+ ### Return a Hash of the host node's state.
67
+ def to_h
68
+ return super.merge(
69
+ addresses: self.addresses.map( &:to_s )
70
+ )
71
+ end
72
+
73
+ end # class Arborist::Node::Resource
@@ -45,6 +45,12 @@ class Arborist::Node::Root < Arborist::Node
45
45
  end
46
46
 
47
47
 
48
+ ### Ignore restores of serialized root nodes.
49
+ def restore( other_node )
50
+ self.log.info "Ignoring restored root node."
51
+ end
52
+
53
+
48
54
  ### Ignore updates to the root node.
49
55
  def update( properties )
50
56
  self.log.warn "Update to the root node ignored."
@@ -6,31 +6,44 @@ require 'ipaddr'
6
6
  require 'socket'
7
7
 
8
8
  require 'arborist/node'
9
+ require 'arborist/mixins'
9
10
 
10
11
 
11
12
  # A node type for Arborist trees that represent services running on hosts.
12
13
  class Arborist::Node::Service < Arborist::Node
14
+ include Arborist::HashUtilities
15
+
13
16
 
14
17
  # The default transport layer protocol to use for services that don't specify
15
18
  # one
16
19
  DEFAULT_PROTOCOL = 'tcp'
17
20
 
18
21
 
19
- ### Create a new Service node.
20
- def initialize( identifier, host, options={}, &block )
21
- my_identifier = "%s-%s" % [ host.identifier, identifier ]
22
- super( my_identifier )
23
-
24
- @host = host
25
- @parent = host.identifier
26
- @app_protocol = options[:app_protocol] || identifier
27
- @protocol = options[:protocol] || DEFAULT_PROTOCOL
22
+ # Services live under Host nodes
23
+ parent_type :host
28
24
 
29
- service_port = options[:port] || default_port_for( @app_protocol, @protocol ) or
30
- raise ArgumentError, "can't determine the port for %s/%s" % [ @app_protocol, @protocol ]
31
- @port = Integer( service_port )
32
25
 
33
- self.instance_eval( &block ) if block
26
+ ### Create a new Service node.
27
+ def initialize( identifier, host, attributes={}, &block )
28
+ raise Arborist::NodeError, "no host given" unless host.is_a?( Arborist::Node::Host )
29
+ qualified_identifier = "%s-%s" % [ host.identifier, identifier ]
30
+
31
+ @host = host
32
+ @app_protocol = nil
33
+ @protocol = nil
34
+ @port = nil
35
+
36
+ attributes[ :app_protocol ] ||= identifier
37
+ attributes[ :protocol ] ||= DEFAULT_PROTOCOL
38
+
39
+ super( qualified_identifier, host, attributes, &block )
40
+
41
+ unless @port
42
+ service_port = default_port_for( @app_protocol, @protocol ) or
43
+ raise ArgumentError, "can't determine the port for %s/%s" %
44
+ [ @app_protocol, @protocol ]
45
+ @port = Integer( service_port )
46
+ end
34
47
  end
35
48
 
36
49
 
@@ -38,17 +51,37 @@ class Arborist::Node::Service < Arborist::Node
38
51
  public
39
52
  ######
40
53
 
41
- ##
42
- # The network port the service uses
43
- attr_reader :port
54
+ ### Set service +attributes+.
55
+ def modify( attributes )
56
+ attributes = stringify_keys( attributes )
57
+
58
+ super
59
+
60
+ self.port( attributes['port'] )
61
+ self.app_protocol( attributes['app_protocol'] )
62
+ self.protocol( attributes['protocol'] )
63
+ end
64
+
65
+
66
+ ### Get/set the port the service is bound to.
67
+ def port( new_port=nil )
68
+ return @port unless new_port
69
+ @port = new_port
70
+ end
44
71
 
45
- ##
46
- # The transport layer protocol the service uses
47
- attr_reader :protocol
48
72
 
49
- ##
50
- # The (layer 7) protocol used by the service
51
- attr_reader :app_protocol
73
+ ### Get/set the (layer 7) protocol used by the service
74
+ def app_protocol( new_proto=nil )
75
+ return @app_protocol unless new_proto
76
+ @app_protocol = new_proto
77
+ end
78
+
79
+
80
+ ### Get/set the transport layer protocol the service uses
81
+ def protocol( new_proto=nil )
82
+ return @protocol unless new_proto
83
+ @protocol = new_proto
84
+ end
52
85
 
53
86
 
54
87
  ### Delegate the service's address to its host.
@@ -96,6 +129,40 @@ class Arborist::Node::Service < Arborist::Node
96
129
  end
97
130
 
98
131
 
132
+ ### Overridden to disallow modification of a Service's parent, as it needs a reference to
133
+ ### the Host node for delegation.
134
+ def parent( new_parent=nil )
135
+ return super unless new_parent
136
+ raise "Can't reparent a service; replace the node instead"
137
+ end
138
+
139
+
140
+ #
141
+ # Serialization
142
+ #
143
+
144
+ ### Return a Hash of the host node's state.
145
+ def to_h
146
+ return super.merge(
147
+ addresses: self.addresses.map(&:to_s),
148
+ protocol: self.protocol,
149
+ app_protocol: self.app_protocol,
150
+ port: self.port
151
+ )
152
+ end
153
+
154
+
155
+ ### Equality operator -- returns +true+ if +other_node+ is equal to the
156
+ ### receiver. Overridden to also compare addresses.
157
+ def ==( other_host )
158
+ return super &&
159
+ other_host.addresses == self.addresses &&
160
+ other_host.protocol == self.protocol &&
161
+ other_host.app_protocol == self.app_protocol &&
162
+ other_host.port == self.port
163
+ end
164
+
165
+
99
166
  #######
100
167
  private
101
168
  #######
@@ -28,7 +28,7 @@ class Arborist::Observer
28
28
  OBSERVER_FILE_PATTERN = '**/*.rb'
29
29
 
30
30
 
31
- Arborist.add_dsl_constructor( :Observer ) do |description, &block|
31
+ Arborist.add_dsl_constructor( self ) do |description, &block|
32
32
  Arborist::Observer.new( description, &block )
33
33
  end
34
34
 
@@ -20,8 +20,9 @@ class Arborist::Subscription
20
20
 
21
21
  ### Instantiate a new Subscription object given an +event+ pattern
22
22
  ### and event +criteria+.
23
- def initialize( publisher, event_type=nil, criteria={} )
24
- @publisher = publisher
23
+ def initialize( event_type=nil, criteria={}, &callback )
24
+ raise LocalJumpError, "requires a callback block" unless callback
25
+ @callback = callback
25
26
  @event_type = event_type
26
27
  @criteria = stringify_keys( criteria )
27
28
  @id = self.generate_id
@@ -32,8 +33,8 @@ class Arborist::Subscription
32
33
  public
33
34
  ######
34
35
 
35
- # The Arborist::Manager::EventPublisher the subscription will use to publish matching events.
36
- attr_reader :publisher
36
+ # The callable that should be called when the subscription receives a matching event
37
+ attr_reader :callback
37
38
 
38
39
  # A unique identifier for this subscription request.
39
40
  attr_reader :id
@@ -54,7 +55,10 @@ class Arborist::Subscription
54
55
  ### Publish any of the specified +events+ which match the subscription.
55
56
  def on_events( *events )
56
57
  events.flatten.each do |event|
57
- self.publisher.publish( self.id, event ) if self.interested_in?( event )
58
+ if self.interested_in?( event )
59
+ self.log.debug "Calling %p for a %s event" % [ self.callback, event.type ]
60
+ self.callback.call( self.id, event )
61
+ end
58
62
  end
59
63
  end
60
64
 
@@ -70,12 +74,13 @@ class Arborist::Subscription
70
74
 
71
75
  ### Return a String representation of the object suitable for debugging.
72
76
  def inspect
73
- return "#<%p:%#x [%s] for %s events matching: %p>" % [
77
+ return "#<%p:%#x [%s] for %s events matching: %p -> %p>" % [
74
78
  self.class,
75
79
  self.object_id * 2,
76
80
  self.id,
77
81
  self.event_type,
78
82
  self.criteria,
83
+ self.callback,
79
84
  ]
80
85
  end
81
86
 
@@ -64,6 +64,20 @@ describe Arborist::Client do
64
64
  end
65
65
 
66
66
 
67
+ it "can list a depth-limited subtree of the node of the managed it's connected to" do
68
+ res = client.list( depth: 2 )
69
+ expect( res ).to be_an( Array )
70
+ expect( res.length ).to eq( 8 )
71
+ end
72
+
73
+
74
+ it "can list a depth-limited subtree of the nodes of the manager it's connected to" do
75
+ res = client.list( from: 'duir', depth: 1 )
76
+ expect( res ).to be_an( Array )
77
+ expect( res.length ).to eq( 5 )
78
+ end
79
+
80
+
67
81
  it "can fetch all node properties for all 'up' nodes" do
68
82
  res = client.fetch
69
83
  expect( res ).to be_a( Hash )
@@ -97,6 +111,18 @@ describe Arborist::Client do
97
111
  end
98
112
 
99
113
 
114
+ it "can fetch all node properties for 'up' nodes that don't match specified criteria" do
115
+ res = client.fetch( {}, properties: [:addresses, :status], exclude: {tag: 'testing'} )
116
+
117
+ testing_nodes = manager.nodes.values.select {|n| n.tags.include?('testing') }
118
+
119
+ expect( res ).to be_a( Hash )
120
+ expect( res ).to_not be_empty()
121
+ expect( res.length ).to eq( manager.nodes.length - testing_nodes.length )
122
+ expect( res.values ).to all( be_a(Hash) )
123
+ end
124
+
125
+
100
126
  it "can fetch all properties for all nodes regardless of their status" do
101
127
  # Down a node
102
128
  manager.nodes['duir'].update( error: 'something happened' )
@@ -110,8 +136,9 @@ describe Arborist::Client do
110
136
 
111
137
 
112
138
  it "can update the properties of managed nodes", :no_ci do
113
- client.update( duir: { ping: {rtt: 24} } )
139
+ res = client.update( duir: { ping: {rtt: 24} } )
114
140
 
141
+ expect( res ).to be_truthy
115
142
  expect( manager.nodes['duir'].properties ).to include( 'ping' )
116
143
  expect( manager.nodes['duir'].properties['ping'] ).to include( 'rtt' )
117
144
  expect( manager.nodes['duir'].properties['ping']['rtt'] ).to eq( 24 )
@@ -190,6 +217,66 @@ describe Arborist::Client do
190
217
  expect( sub.event_type ).to eq( nil )
191
218
  end
192
219
 
220
+
221
+ it "can unsubscribe from events using a subscription ID" do
222
+ sub_id = client.subscribe
223
+ res = client.unsubscribe( sub_id )
224
+ expect( res ).to be_truthy
225
+ expect( manager.subscriptions ).to_not include( sub_id )
226
+ end
227
+
228
+
229
+ it "returns nil without error when unsubscribing to a non-existant subscription" do
230
+ res = client.unsubscribe( 'a_subid' )
231
+ expect( res ).to be_nil
232
+ end
233
+
234
+
235
+ it "can prune nodes from the tree" do
236
+ res = client.prune( 'sidonie-ssh' )
237
+
238
+ expect( res ).to eq( true )
239
+ expect( manager.nodes ).to_not include( 'sidonie-ssh' )
240
+ end
241
+
242
+
243
+ it "returns nil without error when pruning a node that doesn't exist" do
244
+ res = client.prune( 'carrigor' )
245
+ expect( res ).to be_nil
246
+ end
247
+
248
+
249
+ it "can graft new nodes onto the tree" do
250
+ res = client.graft( 'breakfast-burrito', type: 'host' )
251
+ expect( res ).to eq( 'breakfast-burrito' )
252
+ expect( manager.nodes ).to include( 'breakfast-burrito' )
253
+ expect( manager.nodes['breakfast-burrito'] ).to be_a( Arborist::Node::Host )
254
+ expect( manager.nodes['breakfast-burrito'].parent ).to eq( '_' )
255
+ end
256
+
257
+
258
+ it "can graft nodes with attributes onto the tree" do
259
+ res = client.graft( 'breakfast-burrito',
260
+ type: 'service',
261
+ parent: 'duir',
262
+ port: 9999,
263
+ tags: ['yusss']
264
+ )
265
+ expect( res ).to eq( 'duir-breakfast-burrito' )
266
+ expect( manager.nodes ).to include( 'duir-breakfast-burrito' )
267
+ expect( manager.nodes['duir-breakfast-burrito'] ).to be_a( Arborist::Node::Service )
268
+ expect( manager.nodes['duir-breakfast-burrito'].parent ).to eq( 'duir' )
269
+ expect( manager.nodes['duir-breakfast-burrito'].port ).to eq( 9999 )
270
+ expect( manager.nodes['duir-breakfast-burrito'].tags ).to include( 'yusss' )
271
+ end
272
+
273
+
274
+ it "can modify operational attributes of a node" do
275
+ res = client.modify( "duir", tags: 'girlrobot' )
276
+ expect( res ).to be_truthy
277
+ expect( manager.nodes['duir'].tags ).to eq( ['girlrobot'] )
278
+ end
279
+
193
280
  end
194
281
 
195
282
 
@@ -236,7 +323,7 @@ describe Arborist::Client do
236
323
  expect( msg.first['version'] ).to eq( Arborist::Client::API_VERSION )
237
324
  expect( msg.first['action'] ).to eq( 'fetch' )
238
325
 
239
- expect( msg.last ).to eq( {} )
326
+ expect( msg.last ).to eq([ {}, {} ])
240
327
  end
241
328
 
242
329
 
@@ -252,9 +339,10 @@ describe Arborist::Client do
252
339
  expect( msg.first['version'] ).to eq( Arborist::Client::API_VERSION )
253
340
  expect( msg.first['action'] ).to eq( 'fetch' )
254
341
 
255
- expect( msg.last ).to be_a( Hash )
256
- expect( msg.last ).to include( 'type' )
257
- expect( msg.last['type'] ).to eq( 'host' )
342
+ body = msg.last
343
+ expect( body.first ).to be_a( Hash )
344
+ expect( body.first ).to include( 'type' )
345
+ expect( body.first['type'] ).to eq( 'host' )
258
346
  end
259
347
 
260
348