cult 0.1.1.pre

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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +240 -0
  6. data/Rakefile +6 -0
  7. data/cult +1 -0
  8. data/cult.gemspec +38 -0
  9. data/doc/welcome.txt +1 -0
  10. data/exe/cult +86 -0
  11. data/lib/cult/artifact.rb +45 -0
  12. data/lib/cult/cli/common.rb +265 -0
  13. data/lib/cult/cli/console_cmd.rb +124 -0
  14. data/lib/cult/cli/cri_extensions.rb +84 -0
  15. data/lib/cult/cli/init_cmd.rb +116 -0
  16. data/lib/cult/cli/load.rb +26 -0
  17. data/lib/cult/cli/node_cmd.rb +205 -0
  18. data/lib/cult/cli/provider_cmd.rb +123 -0
  19. data/lib/cult/cli/role_cmd.rb +149 -0
  20. data/lib/cult/cli/task_cmd.rb +140 -0
  21. data/lib/cult/commander.rb +103 -0
  22. data/lib/cult/config.rb +22 -0
  23. data/lib/cult/definition.rb +112 -0
  24. data/lib/cult/driver.rb +88 -0
  25. data/lib/cult/drivers/common.rb +192 -0
  26. data/lib/cult/drivers/digital_ocean_driver.rb +179 -0
  27. data/lib/cult/drivers/linode_driver.rb +282 -0
  28. data/lib/cult/drivers/load.rb +26 -0
  29. data/lib/cult/drivers/script_driver.rb +27 -0
  30. data/lib/cult/drivers/vultr_driver.rb +217 -0
  31. data/lib/cult/named_array.rb +129 -0
  32. data/lib/cult/node.rb +62 -0
  33. data/lib/cult/project.rb +169 -0
  34. data/lib/cult/provider.rb +134 -0
  35. data/lib/cult/role.rb +213 -0
  36. data/lib/cult/skel.rb +85 -0
  37. data/lib/cult/task.rb +64 -0
  38. data/lib/cult/template.rb +92 -0
  39. data/lib/cult/transferable.rb +61 -0
  40. data/lib/cult/version.rb +3 -0
  41. data/lib/cult.rb +4 -0
  42. data/skel/.cultconsolerc +4 -0
  43. data/skel/.cultrc.erb +29 -0
  44. data/skel/README.md.erb +22 -0
  45. data/skel/keys/.keep +0 -0
  46. data/skel/nodes/.keep +0 -0
  47. data/skel/providers/.keep +0 -0
  48. data/skel/roles/all/role.json +4 -0
  49. data/skel/roles/all/tasks/00000-do-something-cool +27 -0
  50. data/skel/roles/bootstrap/files/cult-motd +45 -0
  51. data/skel/roles/bootstrap/role.json +4 -0
  52. data/skel/roles/bootstrap/tasks/00000-set-hostname +22 -0
  53. data/skel/roles/bootstrap/tasks/00001-add-cult-user +21 -0
  54. data/skel/roles/bootstrap/tasks/00002-install-cult-motd +9 -0
  55. metadata +183 -0
@@ -0,0 +1,282 @@
1
+ require 'cult/driver'
2
+ require 'cult/cli/common'
3
+
4
+ require 'securerandom'
5
+ require 'time'
6
+
7
+ module Cult
8
+ # This has been submitted as a PR. It lets us set a label and custom
9
+ # expiration length for an API key.
10
+ # See: https://github.com/rick/linode/pull/34
11
+ module LinodeMonkeyPatch
12
+ def fetch_api_key(options = {})
13
+ request = {
14
+ api_action: 'user.getapikey',
15
+ api_responseFormat: 'json',
16
+ username: username,
17
+ password: password
18
+ }
19
+
20
+ if options.key?(:label)
21
+ request[:label] = options[:label]
22
+ end
23
+
24
+ if options.key?(:expires)
25
+ expires = options[:expires]
26
+ request[:expires] = expires.nil? ? 0 : expires
27
+ end
28
+
29
+ response = post(request)
30
+ if error?(response)
31
+ fail "Errors completing request [user.getapikey] @ [#{api_url}] for " +
32
+ "username [#{username}]:\n" +
33
+ "#{error_message(response, 'user.getapikey')}"
34
+ end
35
+ reformat_response(response).api_key
36
+ end
37
+ public :fetch_api_key
38
+
39
+ module_function
40
+ def install!
41
+ ::Linode.prepend(self)
42
+ end
43
+ end
44
+ end
45
+
46
+ module Cult
47
+ module Drivers
48
+ class LinodeDriver < ::Cult::Driver
49
+ self.required_gems = 'linode'
50
+
51
+ include Common
52
+
53
+ SWAP_SIZE = 256
54
+
55
+ attr_reader :client
56
+
57
+ def initialize(api_key:)
58
+ LinodeMonkeyPatch.install!
59
+ @client = Linode.new(api_key: api_key)
60
+ end
61
+
62
+
63
+ def images_map
64
+ client.avail.distributions.select(&:is64bit).map do |v|
65
+ name = v.label
66
+ [ slugify(distro_name(v.label)), v.distributionid ]
67
+ end.to_h
68
+ end
69
+ memoize :images_map
70
+ with_id_mapping :images_map
71
+
72
+
73
+ def zones_map
74
+ client.avail.datacenters.map do |v|
75
+ [ slugify(v.abbr), v.datacenterid ]
76
+ end.to_h
77
+ end
78
+ memoize :zones_map
79
+ with_id_mapping :zones_map
80
+
81
+
82
+ def sizes_map
83
+ client.avail.linodeplans.map do |v|
84
+ name = v.label.gsub(/^Linode /, '')
85
+ if name.match(/^\d+$/)
86
+ mb = name.to_i
87
+ if mb < 1024
88
+ "#{mb}mb"
89
+ else
90
+ name = "#{mb / 1024}gb"
91
+ end
92
+ end
93
+ [ slugify(name), v.planid ]
94
+ end.to_h
95
+ end
96
+ memoize :sizes_map
97
+ with_id_mapping :sizes_map
98
+
99
+
100
+ # We try to use the reasonable sizes that the web UI uses, although the
101
+ # API lets us change it.
102
+ def disk_size_for_size(size)
103
+ gb = 1024
104
+ {
105
+ '2gb' => 24 * gb,
106
+ '4gb' => 48 * gb,
107
+ '8gb' => 96 * gb,
108
+ '12gb' => 192 * gb,
109
+ '24gb' => 384 * gb,
110
+ '48gb' => 768 * gb,
111
+ '64gb' => 1152 * gb,
112
+ '80gb' => 1536 * gb,
113
+ '120gb' => 1920 * gb
114
+ }.fetch(size.to_s)
115
+ end
116
+
117
+
118
+ # I've been told by Linode support that this literal will always mean
119
+ # "Latest x86". But in case that changes...
120
+ def latest_kernel_id
121
+ @latest_kernel_id ||= 138 || begin
122
+ client.avail.kernels.find {|k| k.label.match(/^latest 64 bit/i)}
123
+ end.kernelid
124
+ end
125
+
126
+
127
+ def destroy!(id:)
128
+ client.linode.delete(linodeid: id, skipchecks: true)
129
+ end
130
+
131
+
132
+ def provision!(name:, size:, zone:, image:, ssh_key_files:)
133
+ sizeid = fetch_mapped(name: :size, from: sizes_map, key: size)
134
+ imageid = fetch_mapped(name: :image, from: images_map, key: image)
135
+ zoneid = fetch_mapped(name: :zone, from: zones_map, key: zone)
136
+ disksize = disk_size_for_size(size)
137
+
138
+ linodeid = client.linode.create(datacenterid: zoneid,
139
+ planid: sizeid).linodeid
140
+
141
+ rollback_on_error(id: linodeid) do
142
+ # We give it a name early so we can find it in the Web UI if anything
143
+ # goes wrong.
144
+ client.linode.update(linodeid: linodeid, label: name)
145
+ client.linode.ip.addprivate(linodeid: linodeid)
146
+
147
+ ssh_keys = Array(ssh_key_files).map do |file|
148
+ ssh_key_info(file: file)
149
+ end
150
+
151
+ # You shouldn't run meaningful swap, but this makes the Web UI not
152
+ # scare you, and apparently Linux runs better with ANY swap,
153
+ # regardless of how small. We've matched the small size the Linode
154
+ # Web UI does by default.
155
+ swapid = client.linode.disk.create(linodeid: linodeid,
156
+ label: "Cult: #{name}-swap",
157
+ type: "swap",
158
+ size: SWAP_SIZE).diskid
159
+
160
+ # Here, we create the OS on-node storage
161
+ params = {
162
+ linodeid: linodeid,
163
+ distributionid: imageid,
164
+ label: "Cult: #{name}",
165
+ # Linode's max length is 128, generates longer than that to
166
+ # no get the fixed == and truncates.
167
+ rootpass: SecureRandom.base64(100)[0...128],
168
+ rootsshkey: ssh_keys.map {|k| k[:data] }.join("\n"),
169
+ size: disksize - SWAP_SIZE
170
+ }
171
+
172
+ diskid = client.linode.disk.createfromdistribution(params).diskid
173
+
174
+
175
+ # We don't have to reference the config specifically: It'll be the only
176
+ # configuration that exists, so it'll be used.
177
+ client.linode.config.create(linodeid: linodeid,
178
+ kernelid: latest_kernel_id,
179
+ disklist: "#{diskid},#{swapid}",
180
+ rootdevicenum: 1,
181
+ label: "Cult: Latest Linux-x64")
182
+
183
+ client.linode.reboot(linodeid: linodeid)
184
+
185
+ # Information gathering step...
186
+ all_ips = client.linode.ip.list(linodeid: linodeid)
187
+
188
+ ipv4_public = all_ips.find{ |ip| ip.ispublic == 1 }&.ipaddress
189
+ ipv4_private = all_ips.find{ |ip| ip.ispublic == 0 }&.ipaddress
190
+
191
+ # This is a shame: Linode has awesome support for ipv6, but doesn't
192
+ # expose it in the API.
193
+ ipv6_public = nil
194
+ ipv6_private = nil
195
+
196
+ await_ssh(ipv4_public)
197
+
198
+ return {
199
+ name: name,
200
+ size: size,
201
+ zone: zone,
202
+ image: image,
203
+ ssh_key_files: ssh_keys.map{|k| k[:file]},
204
+ ssh_keys: ssh_keys.map{|k| k[:fingerprint]},
205
+
206
+ id: linodeid,
207
+ created_at: Time.now.iso8601,
208
+ host: ipv4_public,
209
+ ipv4_public: ipv4_public,
210
+ ipv4_private: ipv4_private,
211
+ ipv6_public: ipv6_public,
212
+ ipv6_private: ipv6_private,
213
+ meta: {}
214
+ }
215
+ end
216
+ end
217
+
218
+
219
+ def self.interrupts
220
+ # I hate IRB.
221
+ [Interrupt] + (defined?(IRB) ? [IRB::Abort] : [])
222
+ end
223
+
224
+
225
+ def self.setup!
226
+ super
227
+ LinodeMonkeyPatch.install!
228
+
229
+ linode = nil
230
+ api_key = nil
231
+
232
+ begin
233
+ loop do
234
+ puts "Cult needs an API key. It can get one for you, but will " +
235
+ "need your Linode username and password. If you'd rather "
236
+ "generate it at Linode, hit ctrl-c"
237
+ username = CLI.ask "Username"
238
+ password = CLI.password "Password"
239
+ linode = Linode.new(username: username, password: password)
240
+ begin
241
+ linode.fetch_api_key(label: "Cult", expires: nil)
242
+ api_key = linode.api_key
243
+ fail RuntimeError if api_key.nil?
244
+ puts "Got it! In case you're curious: #{api_key}"
245
+ rescue RuntimeError
246
+ puts "Linode disagreed with your password."
247
+ next if CLI.yes_no?("Try again?")
248
+ end
249
+ break
250
+ end
251
+ rescue *interrupts
252
+ puts
253
+ url = "https://manager.linode.com/profile/api"
254
+ puts "You can obtain an API key for Cult at the following URL:"
255
+ puts " #{url}"
256
+ puts
257
+ CLI.launch_browser(url) if CLI.yes_no?("Open Browser?")
258
+ api_key = CLI.prompt("API Key")
259
+ end
260
+
261
+ linode ||= Linode.new(api_key: api_key)
262
+ resp = linode.test.echo(message: "PING")
263
+ if resp.message != 'PING'
264
+ raise "Didn't respond to ping. Something went wrong."
265
+ end
266
+
267
+ inst = new(api_key: api_key)
268
+
269
+ return {
270
+ api_key: api_key,
271
+ driver: driver_name,
272
+ configurations: {
273
+ sizes: inst.sizes,
274
+ zones: inst.zones,
275
+ images: inst.images,
276
+ }
277
+ }
278
+ end
279
+
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,26 @@
1
+ require 'cult/driver'
2
+ require 'cult/named_array'
3
+
4
+ module Cult
5
+ module Drivers
6
+
7
+ module_function
8
+ def load!
9
+ Dir.glob(File.join(__dir__, "*_driver.rb")).each do |file|
10
+ require file
11
+ end
12
+ end
13
+
14
+
15
+ def all
16
+ Cult::Drivers.constants(false).map do |m|
17
+ Cult::Drivers.const_get(m)
18
+ end.select do |cls|
19
+ ::Cult::Driver > cls
20
+ end.to_named_array
21
+ end
22
+
23
+ end
24
+ end
25
+
26
+ Cult::Drivers.load!
@@ -0,0 +1,27 @@
1
+ require 'cult/driver'
2
+ require 'cult/drivers/common'
3
+
4
+ module Cult
5
+ module Drivers
6
+
7
+ class ScriptDriver < ::Cult::Driver
8
+ include Common
9
+
10
+ def initialize(api_key:)
11
+ fail NotImplementedError
12
+ end
13
+
14
+
15
+ def provision!(name:, size:, zone:, image:, ssh_key_files:, extra: {})
16
+ fail NotImplementedError
17
+ end
18
+
19
+
20
+ def self.setup!
21
+ super
22
+ fail NotImplementedError
23
+ end
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,217 @@
1
+ require 'cult/driver'
2
+ require 'cult/drivers/common'
3
+
4
+ require 'net/ssh'
5
+ require 'time'
6
+
7
+ module Cult
8
+ module Drivers
9
+ class VultrDriver < ::Cult::Driver
10
+ self.required_gems = 'vultr'
11
+
12
+ include Common
13
+
14
+ attr_reader :api_key
15
+
16
+ def initialize(api_key:)
17
+ @api_key = api_key
18
+ end
19
+
20
+
21
+ # This sets the Vultr API key to this instance's api key for the duration
22
+ # of a method call and restores it afterwards.
23
+ def self.with_api_key(method_name)
24
+ unwrapped_name = "#{method_name}_no_api_key".to_sym
25
+ alias_method unwrapped_name, method_name
26
+ define_method(method_name) do |*args, &block|
27
+ old_api_key = Vultr.api_key
28
+ begin
29
+ Vultr.api_key = self.api_key
30
+ return send(unwrapped_name, *args, &block)
31
+ ensure
32
+ Vultr.api_key = old_api_key
33
+ end
34
+ end
35
+ end
36
+
37
+
38
+ def zones_map
39
+ Vultr::Region.list[:result].map do |k, v|
40
+ [slugify(v["regioncode"]), v["DCID"]]
41
+ end.to_h
42
+ end
43
+ memoize :zones_map
44
+ with_id_mapping :zones_map
45
+ with_api_key :zones_map
46
+
47
+
48
+ def images_map
49
+ Vultr::OS.list[:result].select do |k, v|
50
+ # Doing our part to kill x86/32
51
+ v['arch'] == 'x64'
52
+ end.map do |k,v|
53
+ [slugify(distro_name(v["name"])), v["OSID"]]
54
+ end.reject do |k,v|
55
+ %w(custom snapshot backup application).include?(k) ||
56
+ k.match(/^windows/)
57
+ end.to_h
58
+ end
59
+ memoize :images_map
60
+ with_id_mapping :images_map
61
+ with_api_key :images_map
62
+
63
+
64
+ def sizes_map
65
+ Vultr::Plan.list[:result].values.select do |v|
66
+ v["plan_type"] == 'SSD'
67
+ end.map do |v|
68
+ if (m = v["name"].match(/^(\d+) ([MGTP]B) RAM/i))
69
+ _, ram, unit = *m
70
+ ram = ram.to_i
71
+
72
+ if unit == "MB" && ram >= 1024
73
+ ram = ram / 1024
74
+ unit = "GB"
75
+ end
76
+
77
+ if unit == "GB" && ram >= 1024
78
+ ram = ram / 1024
79
+ unit = "TB"
80
+ end
81
+
82
+ ["#{ram}#{unit}".downcase, v["VPSPLANID"] ]
83
+ else
84
+ nil
85
+ end
86
+ end.compact.to_h
87
+ end
88
+ memoize :sizes_map
89
+ with_id_mapping :sizes_map
90
+ with_api_key :sizes_map
91
+
92
+
93
+ def ssh_keys
94
+ Vultr::SSHKey.list[:result].values
95
+ end
96
+ memoize :ssh_keys
97
+ with_api_key :ssh_keys
98
+
99
+
100
+ def upload_ssh_key(file:)
101
+ key = ssh_key_info(file: file)
102
+
103
+ vkey = if (exist = ssh_keys.find {|e| e["ssh_key"] == key[:data] })
104
+ exist
105
+ else
106
+ ssh_keys_dememo!
107
+ Vultr::SSHKey.create(name: "Cult: #{key[:name]}",
108
+ ssh_key: key[:data])[:result]
109
+ end
110
+
111
+ vkey["fingerprint"] = key[:fingerprint]
112
+ vkey
113
+ end
114
+ with_api_key :upload_ssh_key
115
+
116
+
117
+ def fetch_ip(list, type)
118
+ goal = (type == :public ? "main_ip" : "private")
119
+ r = list.find{ |v| v["type"] == goal }
120
+ r.nil? ? nil : r["ip"]
121
+ end
122
+
123
+
124
+ def destroy!(id:)
125
+ Vultr::Server.destroy(SUBID: id)
126
+ end
127
+ with_api_key :destroy!
128
+
129
+
130
+ def provision!(name:, size:, zone:, image:, ssh_key_files:)
131
+ keys = Array(ssh_key_files).map do |filename|
132
+ upload_ssh_key(file: filename)
133
+ end
134
+
135
+ sizeid = fetch_mapped(name: :size, from: sizes_map, key: size)
136
+ imageid = fetch_mapped(name: :image, from: images_map, key: image)
137
+ zoneid = fetch_mapped(name: :zone, from: zones_map, key: zone)
138
+
139
+ r = Vultr::Server.create(DCID: zoneid,
140
+ OSID: imageid,
141
+ VPSPLANID: sizeid,
142
+ enable_ipv6: 'yes',
143
+ enable_private_network: 'yes',
144
+ label: name,
145
+ hostname: name,
146
+ SSHKEYID: keys.map{|v| v["SSHKEYID"] }
147
+ .join(','))
148
+
149
+ subid = r[:result]["SUBID"]
150
+
151
+ rollback_on_error(id: subid) do
152
+ # Wait until it's active, it won't have an IP until then
153
+ backoff_loop do
154
+ r = Vultr::Server.list(SUBID: subid)[:result]
155
+ throw :done if r['status'] == 'active'
156
+ end
157
+
158
+ iplist4 = Vultr::Server.list_ipv4(SUBID: subid)[:result].values[0]
159
+ iplist6 = Vultr::Server.list_ipv6(SUBID: subid)[:result].values[0]
160
+
161
+ host = fetch_ip(iplist4, :public)
162
+ await_ssh(host)
163
+
164
+ return {
165
+ name: name,
166
+ size: size,
167
+ zone: zone,
168
+ image: image,
169
+ ssh_key_files: ssh_key_files,
170
+ ssh_keys: keys.map{|v| v["fingerprint"]},
171
+
172
+ id: subid,
173
+ created_at: Time.now.iso8601,
174
+ host: host,
175
+ ipv4_public: host,
176
+ ipv4_private: fetch_ip(iplist4, :private),
177
+ ipv6_public: fetch_ip(iplist6, :public),
178
+ ipv6_private: fetch_ip(iplist6, :private),
179
+ meta: {}
180
+ }
181
+ end
182
+ end
183
+ with_api_key :provision!
184
+
185
+
186
+ def self.setup!
187
+ super
188
+ url = "https://my.vultr.com/settings/#settingsapi"
189
+ puts "Vultr does not generate multiple API keys, so you'll need to "
190
+ "create one (if it does not exist). You can access your API key "
191
+ "at the following URL:"
192
+ puts
193
+ puts " #{url}"
194
+ puts
195
+
196
+ CLI.launch_browser(url) if CLI.yes_no?("Launch browser?")
197
+
198
+ api_key = CLI.prompt("API Key")
199
+
200
+ unless api_key.match(/^[A-Z2-7]{36}$/)
201
+ puts "That doesn't look like an API key, but I'll trust you"
202
+ end
203
+
204
+ inst = new(api_key: api_key)
205
+ return {
206
+ api_key: api_key,
207
+ driver: driver_name,
208
+ configurations: {
209
+ sizes: inst.sizes,
210
+ zones: inst.zones,
211
+ images: inst.images,
212
+ }
213
+ }
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,129 @@
1
+ # A lot of times, we want a sequential array of objects, but it'd still be
2
+ # really convenient to refer to things by their name. This is particularly
3
+ # painful in the console, where, e.g., nodes can only be referred to by index,
4
+ # and you end up calling `find` a lot.
5
+ #
6
+ # NamedArray is an array, but overloads [] to also work with a String, Symbol,
7
+ # Regexp, or a few other things. e.g., nodes[:something]. It works by finding
8
+ # the first item who responds from `named_array_identifier` with the matching
9
+ # key.
10
+ #
11
+ # By default named_array_identifier returns name, but this can be overridden.
12
+
13
+ module Cult
14
+ class NamedArray < Array
15
+ # Any Array can convert itself to a NamedArray
16
+ module ArrayExtensions
17
+ def to_named_array
18
+ NamedArray.new(self)
19
+ end
20
+ ::Array.include(self)
21
+ end
22
+
23
+
24
+ # This maps #named_array_identifier to #name by default
25
+ module ObjectExtensions
26
+ def named_array_identifier
27
+ name
28
+ end
29
+ ::Object.include(self)
30
+ end
31
+
32
+
33
+ def to_named_array
34
+ self
35
+ end
36
+
37
+
38
+ # Wrap any non-mutating methods that can return an Array,
39
+ # and wrap the result with a NamedArray. This is why NamedArray.select
40
+ # results in a NamedArray instead of an Array
41
+ PROXY_METHODS = %i(& * + - << | collect compact flatten reject reverse
42
+ rotate select shuffle slice sort uniq)
43
+ PROXY_METHODS.each do |method_name|
44
+ define_method(method_name) do |*args, &b|
45
+ r = super(*args, &b)
46
+ r.respond_to?(:to_named_array) ? r.to_named_array : r
47
+ end
48
+ end
49
+
50
+
51
+ # It's unforunate that there's not a Regexp constructor that'll
52
+ # accept this string format with options.
53
+ def build_regexp_from_string(s)
54
+ fail RegexpError, "Isn't a Regexp: #{s}" if s[0] != '/'
55
+ options = extract_regexp_options(s)
56
+ Regexp.new(s[1 ... s.rindex('/')], options)
57
+ end
58
+ private :build_regexp_from_string
59
+
60
+
61
+ def extract_regexp_options(s)
62
+ offset = s.rindex('/')
63
+ fail RegexpError, "Unterminated Regexp: #{s}" if offset == 0
64
+
65
+ trailing = s[offset + 1 ... s.size]
66
+ re_string = "%r!!#{trailing}"
67
+ begin
68
+ (eval re_string).options
69
+ rescue SyntaxError => e
70
+ fail RegexpError, "invalid Regexp options: #{trailing}"
71
+ end
72
+ end
73
+ private :extract_regexp_options
74
+
75
+
76
+ # Returns all keys that match if method == :select, the first if
77
+ # method == :find
78
+ def all(key, method = :select)
79
+ key = case key
80
+ when Integer
81
+ # Fallback to default behavior
82
+ return super
83
+ when String
84
+ key[0] == '/' ? build_regexp_from_string(key) : key
85
+ when Regexp, Proc, Range
86
+ key
87
+ when Symbol
88
+ key.to_s
89
+ when NilClass
90
+ return nil
91
+ else
92
+ fail KeyError, "#{key} did not resolve to an object"
93
+ end
94
+
95
+ send(method) do |v|
96
+ key === v.named_array_identifier
97
+ end
98
+ end
99
+
100
+
101
+ # first matching item
102
+ def [](key)
103
+ return super if key.is_a?(Integer)
104
+ all(key, :find)
105
+ end
106
+
107
+
108
+ # first matching item, or raises KeyError
109
+ def fetch(key)
110
+ all(key, :find) or raise KeyError
111
+ end
112
+
113
+
114
+ def key?(key)
115
+ !! all(key, :find)
116
+ end
117
+ alias_method :exist?, :key?
118
+
119
+
120
+ def keys
121
+ map(&:named_array_identifier)
122
+ end
123
+
124
+
125
+ def values
126
+ self
127
+ end
128
+ end
129
+ end