cult 0.1.1.pre

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