ironfan 4.12.3 → 5.0.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.
@@ -1,6 +1,218 @@
1
1
  module Ironfan
2
2
  class Dsl
3
3
 
4
+ class MachineManifest
5
+ include Gorillib::Model
6
+
7
+ # base server fields
8
+ field :environment, Symbol
9
+ field :name, String
10
+ field :cluster_name, String
11
+ field :facet_name, String
12
+ field :components, Array, of: Ironfan::Dsl::Component, default: []
13
+ field :run_list, Array, of: String, default: []
14
+ field :cluster_default_attributes, Hash
15
+ field :cluster_override_attributes, Hash
16
+ field :facet_default_attributes, Hash
17
+ field :facet_override_attributes, Hash
18
+
19
+ # cloud fields
20
+ field :cloud_name, String
21
+ field :availability_zones, Array, default: []
22
+ field :backing, String
23
+ field :ebs_optimized, :boolean
24
+ field :flavor, String
25
+ field :image_id, String
26
+ field :placement_group, String
27
+ field :elastic_ip, String
28
+ field :auto_elastic_ip, String
29
+ field :allocation_id, String
30
+ field :region, String
31
+ field :ssh_user, String
32
+ field :subnet, String
33
+ field :vpc, String
34
+
35
+ #-----------------------------------------------------------------------------------
36
+ # # FIXME: I haven't determined how to pull some of these fields
37
+ # # in from the remote machines. In fact, some of these
38
+ # # will have to be omitted when comparing. Since
39
+ # # they'll only be necessary when we refactor the
40
+ # # backend to accept manifests for launch, I'm going
41
+ # # to leave these commented out for now. --josh
42
+ #
43
+ # # base server fields
44
+ #
45
+ # field :volumes, Array, of: Volume
46
+ #
47
+ # # cloud fields
48
+ #
49
+ # field :bits, Integer
50
+ # field :bootstrap_distro, String
51
+ # field :chef_client_script, String
52
+ # field :default_availability_zone, String
53
+ # field :elastic_load_balancers, Array, of: Ironfan::Dsl::Ec2::ElasticLoadBalancer, default: []
54
+ # field :iam_server_certificates, Array, of: Ironfan::Dsl::Ec2::IamServerCertificate, default: []
55
+ # field :image_name, String
56
+ # field :keypair, String
57
+ # field :monitoring, String
58
+ # field :mount_ephemerals, Hash
59
+ # field :permanent, :boolean
60
+ # field :provider, Whatever
61
+ # field :security_groups, Array, of: Ironfan::Dsl::Ec2::SecurityGroup
62
+ # field :ssh_identity_dir, String
63
+ # field :validation_key, String
64
+ #-----------------------------------------------------------------------------------
65
+
66
+ # Reconstruct machine manifest from a computer, pulling
67
+ # information from remote sources as necessary.
68
+ def self.from_computer(computer)
69
+ node = get_node(computer.name)
70
+ cluster_name = node['cluster_name']
71
+ facet_name = node['facet_name']
72
+ instance = node['facet_index']
73
+
74
+ from_remote(
75
+ cluster_name,
76
+ facet_name,
77
+ instance,
78
+ node,
79
+ computer.machine,
80
+ computer.server.clouds.to_a.first,
81
+ get_role("#{cluster_name}-cluster"),
82
+ get_role("#{cluster_name}-#{facet_name}-facet")
83
+ )
84
+ end
85
+
86
+ def self.from_remote(cluster_name,
87
+ facet_name,
88
+ instance,
89
+ node,
90
+ machine,
91
+ cloud,
92
+ cluster_role,
93
+ facet_role)
94
+ machine = NilCheckDelegate.new(machine)
95
+ cloud = NilCheckDelegate.new(cloud)
96
+ cluster_role = NilCheckDelegate.new(cluster_role)
97
+ facet_role = NilCheckDelegate.new(facet_role)
98
+
99
+ result = Ironfan::Dsl::MachineManifest.
100
+ receive(
101
+
102
+ # base server fields
103
+
104
+ environment: node.chef_environment,
105
+ name: instance,
106
+ cluster_name: cluster_name,
107
+ facet_name: facet_name,
108
+ components: remote_components(node),
109
+ run_list: remote_run_list(node),
110
+ cluster_default_attributes: (cluster_role.default_attributes || {}),
111
+ cluster_override_attributes: (cluster_role.override_attributes || {}),
112
+ facet_default_attributes: (facet_role.default_attributes || {}),
113
+ facet_override_attributes: (facet_role.override_attributes || {}),
114
+
115
+ # cloud fields
116
+
117
+ backing: machine.root_device_type,
118
+ cloud_name: cloud.name,
119
+ availability_zones: [*machine.availability_zone],
120
+ ebs_optimized: machine.ebs_optimized,
121
+ flavor: machine.flavor_id,
122
+ image_id: machine.image_id,
123
+ keypair: machine.nilcheck_depth(1).key_pair.name,
124
+ monitoring: machine.monitoring,
125
+ placement_group: machine.placement_group,
126
+ region: machine.availability_zone.to_s[/.*-.*-\d+/],
127
+ security_groups: machine.nilcheck_depth(1).groups.map{|x| {name: x}},
128
+ subnet: machine.subnet_id,
129
+ vpc: machine.vpc_id
130
+
131
+ #-----------------------------------------------------------------------------------
132
+ # # FIXME: I haven't determined how to pull some of these fields
133
+ # # in from the remote machines. In fact, some of these
134
+ # # will have to be omitted when comparing. Since
135
+ # # they'll only be necessary when we refactor the
136
+ # # backend to accept manifests for launch, I'm going
137
+ # # to leave these commented out for now. --josh
138
+ #
139
+ # # base server fields
140
+ #
141
+ # volume: local_manifest.volume
142
+ #
143
+ # # cloud fields
144
+ #
145
+ # bits: local_manifest.bits,
146
+ # bootstrap_distro: local_manifest.bootstrap_distro,
147
+ # chef_client_script: local_manifest.chef_client_script,
148
+ # default_availability_zone: local_manifest.default_availability_zone,
149
+ # iam_server_certificates: launch_description.fetch(:iam_server_certificates),
150
+ # image_name: local_manifest.image_name,
151
+ # elastic_load_balancers: launch_description.fetch(:elastic_load_balancers),
152
+ # mount_ephemerals: local_manifest.mount_ephemerals,
153
+ # permanent: local_manifest.permanent,
154
+ # provider: local_manifest.provider,
155
+ # elastic_ip: local_manifest.elastic_ip,
156
+ # auto_elastic_ip: local_manifest.auto_elastic_ip,
157
+ # allocation_id: local_manifest.allocation_id,
158
+ # ssh_user: local_manifest.ssh_user,
159
+ # ssh_identity_dir: local_manifest.ssh_identity_dir,
160
+ # validation_key: local_manifest.validation_key,
161
+ #-----------------------------------------------------------------------------------
162
+ )
163
+ end
164
+
165
+ def to_comparable
166
+ deep_stringify(to_wire.tap do |hsh|
167
+ hsh.delete(:_type)
168
+ hsh.delete(:ssh_user)
169
+ #hsh[:security_groups] = Hash[hsh[:security_groups].map{|x| [x.fetch(:name), x]}]
170
+ hsh[:components] = Hash[hsh.fetch(:components).map do |component|
171
+ [component.fetch(:name), component]
172
+ end]
173
+ hsh[:run_list] = hsh.fetch(:run_list).map do |x|
174
+ x.end_with?(']') ? x : "recipe[#{x}]"
175
+ end
176
+ end)
177
+ end
178
+
179
+ private
180
+
181
+ def deep_stringify obj
182
+ case obj
183
+ when Hash then Hash[obj.map{|k,v| [k.to_s, deep_stringify(v)]}]
184
+ when Array then obj.map{|x| deep_stringify(x)}
185
+ when Symbol then obj.to_s
186
+ else obj
187
+ end
188
+ end
189
+
190
+ def self.get_node(node_name)
191
+ Chef::Node.load(node_name)
192
+ rescue Net::HTTPServerException => ex
193
+ Chef::Node.new
194
+ end
195
+
196
+ def self.get_role(role_name)
197
+ Chef::Role.load(role_name)
198
+ rescue Net::HTTPServerException => ex
199
+ Chef::Role.new
200
+ end
201
+
202
+ def self.remote_components(node)
203
+ announcements = node['components'] || {}
204
+ node['components'].to_a.map do |_, announce|
205
+ name = announce['name'].to_sym
206
+ plugin = Ironfan::Dsl::Compute.plugin_for(name)
207
+ plugin.from_node(node).tap{|x| x.name = name} if plugin
208
+ end.compact
209
+ end
210
+
211
+ def self.remote_run_list(node)
212
+ node.run_list.to_a.map(&:to_s)
213
+ end
214
+ end
215
+
4
216
  class Server < Ironfan::Dsl::Compute
5
217
  field :cluster_name, String
6
218
  field :facet_name, String
@@ -38,6 +250,81 @@ module Ironfan
38
250
  errors['missing cluster/facet/server'] = [cluster_name, facet_name, name] unless (cluster_name && facet_name && name)
39
251
  errors
40
252
  end
253
+
254
+ def to_machine_manifest
255
+ cloud = clouds.each.to_a.first
256
+ MachineManifest.receive(
257
+
258
+ # base server fields
259
+
260
+ environment: environment,
261
+ name: name,
262
+ cluster_name: cluster_name,
263
+ facet_name: facet_name,
264
+ run_list: run_list,
265
+ components: components,
266
+ cluster_default_attributes: cluster_role.default_attributes,
267
+ cluster_override_attributes: cluster_role.override_attributes,
268
+ facet_default_attributes: facet_role.default_attributes,
269
+ facet_override_attributes: facet_role.override_attributes,
270
+ volumes: volumes,
271
+
272
+ # cloud fields
273
+
274
+ cloud_name: cloud.name,
275
+
276
+ availability_zones: cloud.availability_zones,
277
+ backing: cloud.backing,
278
+ bits: cloud.bits,
279
+ bootstrap_distro: cloud.bootstrap_distro,
280
+ chef_client_script: cloud.chef_client_script,
281
+ default_availability_zone: cloud.default_availability_zone,
282
+ elastic_load_balancers: cloud.elastic_load_balancers,
283
+ ebs_optimized: cloud.ebs_optimized,
284
+ flavor: cloud.flavor,
285
+ iam_server_certificates: cloud.iam_server_certificates,
286
+ image_id: cloud.image_id,
287
+ image_name: cloud.image_name,
288
+ keypair: cloud.keypair,
289
+ monitoring: cloud.monitoring,
290
+ mount_ephemerals: cloud.mount_ephemerals,
291
+ permanent: cloud.permanent,
292
+ placement_group: cloud.placement_group,
293
+ provider: cloud.provider,
294
+ elastic_ip: cloud.elastic_ip,
295
+ auto_elastic_ip: cloud.auto_elastic_ip,
296
+ allocation_id: cloud.allocation_id,
297
+ region: cloud.region,
298
+ security_groups: cloud.security_groups,
299
+ ssh_user: cloud.ssh_user,
300
+ ssh_identity_dir: cloud.ssh_identity_dir,
301
+ subnet: cloud.subnet,
302
+ validation_key: cloud.validation_key,
303
+ vpc: cloud.vpc
304
+
305
+ )
306
+ end
307
+
308
+ def canonical_machine_manifest_hash
309
+ self.class.canonicalize(to_machine_manifest)
310
+ end
311
+
312
+ private
313
+
314
+ def self.canonicalize(item)
315
+ case item
316
+ when Array, Gorillib::ModelCollection
317
+ item.each.map{|i| canonicalize(i)}
318
+ when Ironfan::Dsl::Component
319
+ canonicalize(item.to_manifest)
320
+ when Gorillib::Builder, Gorillib::Model
321
+ canonicalize(item.to_wire.tap{|x| x.delete(:_type)})
322
+ when Hash then
323
+ Hash[item.sort.map{|k,v| [k, canonicalize(v)]}]
324
+ else
325
+ item
326
+ end
327
+ end
41
328
  end
42
329
 
43
330
  end
@@ -28,6 +28,7 @@ module Ironfan
28
28
  VOLUME_IDS.merge!({
29
29
  :blank_xfs => 'snap-d9c1edb1',
30
30
  :blank_xfs_tokyo => 'snap-049d1921',
31
+ :blank_xfs_california => 'snap-514b5c5a', # us-west-1
31
32
  })
32
33
 
33
34
  def snapshot_id(*)
data/lib/ironfan/dsl.rb CHANGED
@@ -2,6 +2,138 @@ module Ironfan
2
2
 
3
3
  class Dsl < Builder
4
4
  include Gorillib::Resolution
5
- end
6
5
 
6
+ def self.default_cookbook_reqs
7
+ @default_cookbook_reqs ||= []
8
+ end
9
+
10
+ def self.cookbook_req name, constraint
11
+ default_cookbook_reqs << new_req(name, constraint)
12
+
13
+ end
14
+
15
+ def join_req req1, req2
16
+ # order requirements by operation: =, >=, ~>
17
+ req1, req2 = (req1.constraint < req2.constraint) ? [req1, req2] : [req2, req1]
18
+ cn1, cn2 = [req1.constraint, req2.constraint]
19
+ vers1, vers2 = [cn1.split.last, cn2.split.last]
20
+ vers1_c, vers2_c = [vers1.split('.'), vers2.split('.')]
21
+ op1, op2 = [req1, req2].map{|x| x.constraint.split.first}
22
+
23
+ if op1 == '=' and op2 == '='
24
+ join_eq_eq(req1, req2)
25
+ elsif op1 == '>=' and op2 == '>='
26
+ join_geq_geq(req1, req2)
27
+ elsif op1 == '~>' and op2 == '~>'
28
+ join_agt_agt(req1, req2)
29
+ elsif op1 == '=' and op2 == '>='
30
+ join_eq_gte(req1, req2)
31
+ elsif op1 == '=' and op2 == '~>'
32
+ join_eq_agt(req1, req2)
33
+ elsif op1 == '>=' and op2 == '~>'
34
+ join_gte_agt(req1, req2)
35
+ end
36
+ end
37
+
38
+ def cookbook_req name, constraint
39
+ (@cookbook_reqs ||= []) << self.class.new_req(name, constraint)
40
+ end
41
+
42
+ def children() [] end
43
+
44
+ def cookbook_reqs
45
+ Hash[_cookbook_reqs.map{|x| [x.name, x.constraint]}]
46
+ end
47
+
48
+ def _cookbook_reqs
49
+ [
50
+ *shallow_cookbook_reqs,
51
+ *child_cookbook_reqs
52
+ ].group_by(&:name).values.map do |group|
53
+ group.inject{|result, req| join_req(result, req)}
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ #-----------------------------------------------------------------------------------------------
60
+
61
+ def join_eq_eq(req1, req2)
62
+ (vers(req1) == vers(req2)) ? req1 : bad_reqs(req1, req2)
63
+ end
64
+
65
+ def join_geq_geq(req1, req2)
66
+ (vers(req1) >= vers(req2)) ? req1 : req2
67
+ end
68
+
69
+ def join_agt_agt(req1, req2)
70
+ if vers_a(req1).size == vers_a(req2).size and
71
+ vers_a_head(req1).zip(vers_a_head(req2)).all?{|v1,v2| v1 == v2}
72
+ (req1.constraint > req2.constraint) ? req1 : req2
73
+ else
74
+ bad_reqs(req1, req2)
75
+ end
76
+ end
77
+
78
+ def join_eq_gte(req1, req2)
79
+ vers(req1) >= vers(req2) ? req1 : bad_reqs(req1, req2)
80
+ end
81
+
82
+ def join_eq_agt(req1, req2)
83
+ if match_v_head(req1, req2) and
84
+ vers_a(req1)[vers_a(req2).size - 1] >= vers_a(req2).last
85
+ req1
86
+ else
87
+ bad_reqs(req1, req2)
88
+ end
89
+ end
90
+
91
+ def join_gte_agt(req1, req2)
92
+ if match_v_head(req1, req2) and vers(req1) <= vers(req2)
93
+ req2
94
+ else
95
+ bad_reqs(req1, req2)
96
+ end
97
+ end
98
+
99
+ def match_v_head(req1, req2)
100
+ vers_a_head(req1, vers_a(req2).size).zip(vers_a_head(req2)).all?{|v1,v2| v1 == v2}
101
+ end
102
+
103
+ def op(req)
104
+ req.constraint.split.first
105
+ end
106
+
107
+ def vers(req)
108
+ req.constraint.split.last
109
+ end
110
+
111
+ def vers_a(req)
112
+ vers(req).split('.')
113
+ end
114
+
115
+ def vers_a_head(req, last = 0)
116
+ vers_a(req)[0...last-1]
117
+ end
118
+
119
+ #-----------------------------------------------------------------------------------------------
120
+
121
+ def bad_reqs(req1, req2)
122
+ raise ArgumentError.new("#{req1.name}: cannot reconcile #{req1.constraint} with #{req2.constraint}")
123
+ end
124
+
125
+ def child_cookbook_reqs
126
+ children.map(&:_cookbook_reqs).flatten(1)
127
+ end
128
+
129
+ def self.new_req(name, constraint)
130
+ raise StandardError.new("Please don't use >= constraints. They're too vague!") if
131
+ constraint.start_with?('>=') and not (@@testing ||= false)
132
+ Ironfan::Plugin::CookbookRequirement.new(name: name, constraint: constraint)
133
+ end
134
+
135
+ def shallow_cookbook_reqs
136
+ @cookbook_reqs || self.class.default_cookbook_reqs
137
+ end
138
+ end
7
139
  end
@@ -15,9 +15,11 @@ module Ironfan
15
15
  end
16
16
 
17
17
  class Dsl < Builder
18
+ class Component < Ironfan::Dsl; end
18
19
  class Compute < Ironfan::Dsl; end
19
20
  class Cluster < Ironfan::Dsl::Compute; end
20
21
  class Facet < Ironfan::Dsl::Compute; end
22
+ class Realm < Ironfan::Dsl::Compute; end
21
23
  class Server < Ironfan::Dsl::Compute; end
22
24
 
23
25
  class Role < Ironfan::Dsl; end
@@ -36,6 +38,11 @@ module Ironfan
36
38
  end
37
39
  end
38
40
 
41
+ module Plugin
42
+ class CookbookRequirement; end
43
+ module Base; end
44
+ end
45
+
39
46
  class Provider < Builder
40
47
  class Resource < Builder; end
41
48
  end
@@ -0,0 +1,89 @@
1
+ require 'gorillib/model'
2
+ require 'gorillib/builder'
3
+ require 'gorillib/string/inflections'
4
+ require 'gorillib/metaprogramming/concern'
5
+
6
+ Gorillib::Model::Field.class_eval do
7
+ field :node_attr, String, default: nil
8
+ end
9
+
10
+ module Ironfan
11
+
12
+ module Pluggable
13
+ def add_plugin name, cls
14
+ registry[name] = cls
15
+ end
16
+ def plugin_for name
17
+ registry[name]
18
+ end
19
+ def registry() @registry ||= {}; end
20
+ end
21
+
22
+ module Plugin
23
+ class CookbookRequirement
24
+ include Gorillib::Builder
25
+
26
+ magic :name, String
27
+ magic :constraint, String
28
+
29
+ def <=>(other)
30
+ self.name <=> other.name
31
+ end
32
+ end
33
+
34
+ module Base
35
+ extend Gorillib::Concern
36
+
37
+ def to_node
38
+ Chef::Node.new.tap do |node|
39
+ self.class.fields.select{|_,x| x.node_attr}.each do |_,x|
40
+ val = send(x.name)
41
+ (keys = x.node_attr.split('.'))[0...-1].inject(node.set) do |hsh,key|
42
+ hsh[key]
43
+ end[keys.last] = val unless val.nil?
44
+ end
45
+ end
46
+ end
47
+
48
+ module ClassMethods
49
+ attr_reader :cookbook_reqs
50
+ attr_reader :plugin_name
51
+
52
+ def from_node(node = NilCheckDelegate.new(nil))
53
+ new(Hash[
54
+ fields.select{|_,x| x.node_attr}.map do |_,x|
55
+ [x.name, (x.node_attr.split('.').inject(node) do |hsh,attr|
56
+ if hsh
57
+ (val = hsh[attr]).is_a?(Mash) ? val.to_hash : val
58
+ end
59
+ end)]
60
+ end.reject{|_,v| v.nil?}
61
+ ])
62
+ end
63
+
64
+ def register_with cls, &blk
65
+ (@dest_class = cls).class_eval{ extend Ironfan::Pluggable }
66
+ end
67
+
68
+ def template plugin_name_parts, base_class=self, &blk
69
+ plugin_name_parts = [*plugin_name_parts]
70
+ full_name = plugin_name_parts.map(&:to_s).join('_').to_sym
71
+ plugin_name = plugin_name_parts.first.to_sym
72
+
73
+ Class.new(base_class, &blk).tap do |plugin_class|
74
+ plugin_class.class_eval{ @plugin_name = plugin_name }
75
+
76
+ self.const_set(full_name.to_s.camelize.to_sym, plugin_class)
77
+
78
+ @dest_class.class_eval do
79
+ add_plugin(full_name, plugin_class)
80
+ define_method(full_name) do |*args, &blk|
81
+ plugin_class.plugin_hook self, (args.first || {}), plugin_name, full_name, &blk
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -69,6 +69,8 @@ module Ironfan
69
69
  groups_to_create = [ ]
70
70
  authorizations_to_ensure = [ ]
71
71
 
72
+ computers.each{|comp| ensure_groups(comp) if Ec2.applicable(comp) } # Add facet and cluster security groups for the computer
73
+
72
74
  # First, deduce the list of all groups to which at least one instance belongs
73
75
  # We'll use this later to decide whether to create groups, or authorize access,
74
76
  # using a VPC security group or an EC2 security group.
@@ -76,7 +78,6 @@ module Ironfan
76
78
  groups_to_create << groups_that_should_exist
77
79
 
78
80
  computers.select { |computer| Ec2.applicable computer }.each do |computer|
79
- ensure_groups(computer) # Add facet and cluster security groups for the computer
80
81
  cloud = computer.server.cloud(:ec2)
81
82
  cluster_name = computer.server.cluster_name
82
83
 
@@ -2,13 +2,19 @@
2
2
  require 'gorillib/builder'
3
3
  require 'gorillib/resolution'
4
4
 
5
+ require 'gorillib/nil_check_delegate'
6
+
5
7
  # Pre-declaration of class hierarchy
6
8
  require 'ironfan/headers'
7
9
 
10
+ # Ironfan plugin mixin
11
+ require 'ironfan/plugin/base'
12
+
8
13
  # DSL for cluster descriptions
9
14
  require 'ironfan/dsl'
10
15
  require 'ironfan/builder'
11
16
 
17
+ require 'ironfan/dsl/component'
12
18
  require 'ironfan/dsl/compute'
13
19
  require 'ironfan/dsl/server'
14
20
  require 'ironfan/dsl/facet'
data/lib/ironfan.rb CHANGED
@@ -91,6 +91,18 @@ module Ironfan
91
91
  end
92
92
  end
93
93
 
94
+ def self.load_realm(name)
95
+ name = name.to_sym
96
+ raise ArgumentError, "Please supply a realm name" if name.to_s.empty?
97
+ return @@realms[name] if @@realms[name]
98
+
99
+ load_cluster_files
100
+
101
+ unless @@realms[name] then die("Couldn't find a realm definition for #{name} in #{cluster_path}") end
102
+
103
+ @@realms[name]
104
+ end
105
+
94
106
  #
95
107
  # Return cluster if it's defined. Otherwise, search Ironfan.cluster_path
96
108
  # for an eponymous file, load it, and return the cluster it defines.
@@ -104,16 +116,21 @@ module Ironfan
104
116
  raise ArgumentError, "Please supply a cluster name" if name.to_s.empty?
105
117
  return @@clusters[name] if @@clusters[name]
106
118
 
119
+ load_cluster_files
120
+
121
+ unless @@clusters[name] then die("Couldn't find a cluster definition for #{name} in #{cluster_path}") end
122
+
123
+ @@clusters[name]
124
+ end
125
+
126
+ def self.load_cluster_files
107
127
  cluster_path.each do |cp_dir|
108
128
  Dir[ File.join(cp_dir, '*.rb') ].each do |filename|
109
129
  Chef::Log.info("Loading cluster file #{filename}")
110
130
  require filename
131
+ clusters.values.each{|cluster| cluster.source_file ||= filename}
111
132
  end
112
133
  end
113
-
114
- unless @@clusters[name] then die("Couldn't find a cluster definition for #{name} in #{cluster_path}") end
115
-
116
- @@clusters[name]
117
134
  end
118
135
 
119
136
  #