arborist 0.0.1.pre20160128152542 → 0.0.1.pre20160606141735

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.
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