misty 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +222 -1
  3. data/lib/misty.rb +8 -0
  4. data/lib/misty/auth.rb +60 -0
  5. data/lib/misty/auth/auth_v2.rb +46 -0
  6. data/lib/misty/auth/auth_v3.rb +58 -0
  7. data/lib/misty/autoload.rb +82 -0
  8. data/lib/misty/cloud.rb +133 -0
  9. data/lib/misty/http/client.rb +115 -0
  10. data/lib/misty/http/direct.rb +26 -0
  11. data/lib/misty/http/method_builder.rb +96 -0
  12. data/lib/misty/http/request.rb +75 -0
  13. data/lib/misty/misty.rb +51 -0
  14. data/lib/misty/openstack/aodh/aodh_v2.rb +12 -0
  15. data/lib/misty/openstack/aodh/v2.rb +20 -0
  16. data/lib/misty/openstack/ceilometer/ceilometer_v2.rb +13 -0
  17. data/lib/misty/openstack/ceilometer/v2.rb +20 -0
  18. data/lib/misty/openstack/cinder/cinder_v1.rb +35 -0
  19. data/lib/misty/openstack/cinder/cinder_v3.rb +148 -0
  20. data/lib/misty/openstack/cinder/v1.rb +24 -0
  21. data/lib/misty/openstack/cinder/v3.rb +24 -0
  22. data/lib/misty/openstack/designate/designate_v2.rb +69 -0
  23. data/lib/misty/openstack/designate/v2.rb +20 -0
  24. data/lib/misty/openstack/glance/glance_v1.rb +16 -0
  25. data/lib/misty/openstack/glance/glance_v2.rb +29 -0
  26. data/lib/misty/openstack/glance/v1.rb +20 -0
  27. data/lib/misty/openstack/glance/v2.rb +20 -0
  28. data/lib/misty/openstack/heat/heat_v1.rb +85 -0
  29. data/lib/misty/openstack/heat/v1.rb +24 -0
  30. data/lib/misty/openstack/ironic/ironic_v1.rb +71 -0
  31. data/lib/misty/openstack/ironic/v1.rb +26 -0
  32. data/lib/misty/openstack/karbor/karbor_v1.rb +32 -0
  33. data/lib/misty/openstack/karbor/v1.rb +20 -0
  34. data/lib/misty/openstack/keystone/keystone_v2_0.rb +11 -0
  35. data/lib/misty/openstack/keystone/keystone_v2_0_ext.rb +32 -0
  36. data/lib/misty/openstack/keystone/keystone_v3.rb +147 -0
  37. data/lib/misty/openstack/keystone/keystone_v3_ext.rb +124 -0
  38. data/lib/misty/openstack/keystone/v2_0.rb +23 -0
  39. data/lib/misty/openstack/keystone/v3.rb +23 -0
  40. data/lib/misty/openstack/magnum/magnum_v1.rb +41 -0
  41. data/lib/misty/openstack/magnum/v1.rb +26 -0
  42. data/lib/misty/openstack/manila/manila_v2.rb +143 -0
  43. data/lib/misty/openstack/manila/v2.rb +26 -0
  44. data/lib/misty/openstack/microversion.rb +62 -0
  45. data/lib/misty/openstack/neutron/neutron_v2_0.rb +205 -0
  46. data/lib/misty/openstack/neutron/v2_0.rb +20 -0
  47. data/lib/misty/openstack/nova/nova_v2_1.rb +269 -0
  48. data/lib/misty/openstack/nova/v2_1.rb +40 -0
  49. data/lib/misty/openstack/sahara/sahara_v1_1.rb +77 -0
  50. data/lib/misty/openstack/sahara/v1_1.rb +20 -0
  51. data/lib/misty/openstack/searchlight/searchlight_v1.rb +15 -0
  52. data/lib/misty/openstack/searchlight/v1.rb +20 -0
  53. data/lib/misty/openstack/senlin/senlin_v1.rb +66 -0
  54. data/lib/misty/openstack/senlin/v1.rb +20 -0
  55. data/lib/misty/openstack/swift/swift_v1.rb +23 -0
  56. data/lib/misty/openstack/swift/v1.rb +20 -0
  57. data/lib/misty/openstack/trove/trove_v1_0.rb +51 -0
  58. data/lib/misty/openstack/trove/v1_0.rb +20 -0
  59. data/lib/misty/openstack/zaqar/v2.rb +20 -0
  60. data/lib/misty/openstack/zaqar/zaqar_v2.rb +46 -0
  61. data/lib/misty/version.rb +2 -2
  62. data/test/integration/compute_test.rb +35 -0
  63. data/test/integration/network_test.rb +34 -0
  64. data/test/integration/orchestration_test.rb +92 -0
  65. data/test/integration/test_helper.rb +19 -0
  66. data/test/integration/vcr/compute_using_nova_v2_1.yml +1107 -0
  67. data/test/integration/vcr/network_using_neutron_v2_0.yml +1029 -0
  68. data/test/integration/vcr/orchestration_using_heat_v1.yml +1457 -0
  69. data/test/unit/auth_helper.rb +52 -0
  70. data/test/unit/auth_test.rb +99 -0
  71. data/test/unit/cloud/requests_test.rb +113 -0
  72. data/test/unit/cloud/services_test.rb +171 -0
  73. data/test/unit/cloud_test.rb +145 -0
  74. data/test/unit/http/client_test.rb +74 -0
  75. data/test/unit/http/direct_test.rb +103 -0
  76. data/test/unit/http/method_builder_test.rb +133 -0
  77. data/test/unit/http/request_test.rb +123 -0
  78. data/test/unit/misty_test.rb +36 -0
  79. data/test/unit/openstack/APIs_test.rb +40 -0
  80. data/test/unit/openstack/microversion_test.rb +70 -0
  81. data/test/unit/service_helper.rb +25 -0
  82. data/test/unit/test_helper.rb +8 -0
  83. metadata +170 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: bdf55abfb46eb97427695bde44d502dbfe21e26b
4
- data.tar.gz: b6359b19815a9e30c6c1f5cce091fee7d956e73e
3
+ metadata.gz: ad9014122c54bf48aae6d26bfc8056ba3eca7e40
4
+ data.tar.gz: f0e8ad48f12c7011f84f83b460bef8651fdc0eb7
5
5
  SHA512:
6
- metadata.gz: 77b92ee3e216632d8091f5dfd65bae72e794979d22a5efc3b255b7b214aefa1ebb90f4ed8ecaf1d03c9a8060b1b9734d49a828e587f7ea25e6bc45398b6dd676
7
- data.tar.gz: c94d2805b9a0faa2e193d691effb9cc24a3d5b4b876bc8683084e95f3e83bf89e5250c4b7ede4617d8ce5c98fe3af7372ffa4603ea579c77ffe0779c48e6ef8f
6
+ metadata.gz: 9f59bba8de14cd8657b22db9eff2b63e9ee08abe4f65fba9b8e15015547a5e4d3d3032143389b44cd6c029eb3911a2a9b66056f77445b1860674456d2a72c9c2
7
+ data.tar.gz: 024efa940ad8cb5ec44ed28effd7fc7a399225a3a7fa592341def537d64492ac24911d480bc3e3351bd389a4ac19a75ae8a71b898e6a00edfdb24a5d9b04849d
data/README.md CHANGED
@@ -1 +1,222 @@
1
- # Misty is coming soon!
1
+ # Introduction
2
+ Misty is a HTTP client for OpenStack APIs, aiming to be fast and to provide a flexible and at same time exhaustive
3
+ APIs experience.
4
+
5
+ ## Features
6
+ * Exhaustive and latest Openstack APIs
7
+ * Multiple Service versions
8
+ * Microversions
9
+ * Based upon Net/HTTP
10
+ * Minimalistic gem dependencies - Only json is required
11
+ * Dynamic services by autoloading only required service's version
12
+ * Token automatically refreshed when expired
13
+ * Raw JSON or Ruby format for queries and responses
14
+ * Persistent HTTP connections (default since HTTP 1.1 anyway) but for the authentication bootstrapping
15
+ * Direct HTTP Methods for custom needs
16
+
17
+ ## A solid KISS
18
+ For REST transactions, Misty uses only Net/HTTP from the Ruby Standard Library. So besides 'json', no other gem are
19
+ required.
20
+ Because OpenStack authentication and Service Catalog management are very specific and shared by all the APIs, once taken
21
+ care of, there is no need for a complex HTTP framework.
22
+ This offers a solid foundation with reduced dependencies.
23
+
24
+ ## APIs Definitions
25
+ The rich variety of OpenStack projects requires lots of Application Program Interfaces to handle.
26
+ Maintaining and extending those APIs is a structural complexity challenge.
27
+ Therefore the more automated the process, the better.
28
+ Thanks to the help of Phoenix project, the OpenStack API-ref [1] provides standardization of the OpenStack APIs.
29
+ The APIs can be processed almost automatically from the API-ref reference manuals (misty-builder).
30
+ This allows:
31
+ * More consistent APIs using automated control
32
+ * More recent APIs definitions
33
+ * Easier to add APIs
34
+
35
+ [1] https://developer.openstack.org/api-guide/quick-start/
36
+
37
+ # Install & Use
38
+
39
+ ## Fetch and install
40
+ ``` ruby
41
+ gem install misty
42
+ ```
43
+
44
+ ## Quick start
45
+ ```ruby
46
+ require 'misty'
47
+
48
+ auth_v3 = {
49
+ :url => "http://localhost:5000",
50
+ :user => "admin",
51
+ :password => "secret",
52
+ :project => "admin",
53
+ :domain => "default"
54
+ }
55
+
56
+ openstack = Misty::Cloud.new(:auth => auth_v3)
57
+
58
+ puts openstack.compute.list_servers.body
59
+ puts openstack.compute.list_flavors.body
60
+ networks = openstack.network.list_networks
61
+ network_id = networks.body["networks"][0]['id']
62
+ network = openstack.network.show_network_details(network_id)
63
+ puts network.body
64
+ ```
65
+
66
+ ## Services
67
+ Once the Misty::Cloud object is created, the Openstack services can be used.
68
+
69
+ The Cloud object authenticates against the identity server (bootstrap process) and obtains the service catalog.
70
+ When an OpenStack API service is used, its endpoint is determined from the catalog and the service is dynamically called
71
+ by Misty so only the services used are loaded.
72
+
73
+ The service generic name, such as `compute`, is used to submit requests with an OpenStack service API.
74
+
75
+ ```ruby
76
+ openstack = Misty::Cloud.new(:auth => { ... })
77
+ openstack.compute.list_servers
78
+ openstack.network.list_networks
79
+ openstack.network.create_network("network": {"name": "my-network"})
80
+ ```
81
+
82
+ To obtain the list of supported services:
83
+ ```ruby
84
+ require 'misty'
85
+ puts Misty.services
86
+ ```
87
+
88
+ Which produces the equivalent of the following:
89
+
90
+ name | project | versions
91
+ --- | --- | ---
92
+ alarming | aodh | ["v2"]
93
+ baremetal | ironic | ["v1"]
94
+ block_storage | cinder | ["v3", "v1"]
95
+ clustering | senlin | ["v1"]
96
+ compute | nova | ["v2.1"]
97
+ container | magnum | ["v1"]
98
+ data_processing | sahara | ["v1.1"]
99
+ data_protection | karbor | ["v1"]
100
+ database | trove | ["v1.0"]
101
+ dns | designate | ["v2"]
102
+ identity | keystone | ["v3", "v2.0"]
103
+ image | glance | ["v2", "v1"]
104
+ messaging | zaqar | ["v2"]
105
+ metering | ceilometer | ["v2"]
106
+ network | neutron | ["v2.0"]
107
+ object_storage | swift | ["v1"]
108
+ orchestration | heat | ["v1"]
109
+ search | searchlight | ["v1"]
110
+ shared_file_systems | manila | ["v2"]
111
+
112
+ * Notes
113
+ When an Openstack service requires a different service name, the :service_names option can be used (see below).
114
+
115
+ The #requests method provides the available requests for a service, for example:
116
+ ```ruby
117
+ openstack.compute.requests
118
+ ```
119
+
120
+ ## Setup
121
+
122
+ ### Authentication
123
+ The URL and credentials details are necessary to authenticate with the identity server (Keystone).
124
+
125
+ To provide a Keystone V3, which is the default recommended version:
126
+ ```ruby
127
+ auth = {
128
+ :url => "http://localhost:5000",
129
+ :user => "admin",
130
+ :password => "secret",
131
+ :project => "admin",
132
+ :domain => "default"
133
+ }
134
+ }
135
+ ```
136
+ Alternatively, for Keystone V2, just provide the tenant details, Misty will detect it's using Keystone V2:
137
+ ```ruby
138
+ auth = {
139
+ :url => "http://localhost:5000",
140
+ :user => "admin",
141
+ :password => "secret",
142
+ :tenant => "admin",
143
+ }
144
+ ```
145
+
146
+ ### Global options
147
+ Besides the authentication details, the following options which apply for the whole cloud
148
+
149
+ * :content_type
150
+ Format of the body of the successful HTTP responses to be JSON or Ruby structures.
151
+ Value is symbol. Allowed value is `:json`.
152
+ By default response body are converted to Ruby structures.
153
+ * :log_file
154
+ File name and path for log file.
155
+ Value is file path or IO object - Default is `./misty.log`.
156
+ For example: use STDOUT for terminal output or alternatively use '/dev/null' to avoid the logs entirely.
157
+ * :log_level
158
+ Value is Fixnum - Default is 1 (Logger::INFO) - See Logger from Ruby standard Library
159
+
160
+ ```ruby
161
+ openstack = Misty::Cloud.new(:auth => auth, :content_type => :ruby, :log_file => STDOUT)
162
+ ```
163
+
164
+ ### Services Options
165
+ Each service can get options specifically:
166
+
167
+ ```ruby
168
+ openstack = Misty::Cloud.new(:auth => auth, :identity => {}, :compute => {})
169
+ ```
170
+
171
+ The following options are available:
172
+ * :api_version
173
+ The latest supported version is used by default. See Misty.services to use another version.
174
+ Value is a STRING
175
+ * :base_path
176
+ Allows to force the base path for every URL requests.
177
+ Value is a STRING
178
+ * :base_url
179
+ Allows to force the base URL for every requests.
180
+ Value is a STRING
181
+ * :interface
182
+ Allows to provide an alternate interface. Allowed values are "public", "internal" or "admin"
183
+ Value is a STRING - Default = "public"
184
+ * :region_id
185
+ Value is a STRING
186
+ Default "regionOne"
187
+ * :service_names
188
+ Value is a STRING - Default is defined by Misty.services
189
+ Allows to use a difference name for the service. For instance "identity3" for the identity service.
190
+ * :ssl_verify_mode
191
+ Used in SSL mode (detected from the URI)
192
+ Value is a BOOLEAN - Default is `true`
193
+ * :version
194
+ Version to be used when microversion is supported by the service.
195
+ Value is a STRING - Default is `"CURRENT"`
196
+ Allowed values are "CURRENT", "LATEST", "SUPPORTED", or a version number such as "2.0" or "3"
197
+
198
+ Example:
199
+ ```ruby
200
+ openstack = Misty::Cloud.new(:auth => auth, :log_level => 0, :identity => {:region_id => "regionTwo"}, :compute => {:version => "2.27", :interface => "admin"})
201
+ ```
202
+
203
+ ## Direct REST HTTP Methods
204
+ To send requests directly use the 'get', 'delete', 'post' and 'put' methods directly:
205
+ ```ruby
206
+ openstack.network.post("/v2.0/qos/policies/48985e6b8da145699d411f12a3459fca/dscp_marking_rules", data)
207
+ ```
208
+ # Requirements
209
+
210
+ ## Ruby versions tested
211
+ * Ruby 2.4.0
212
+ * Ruby 2.3.3
213
+ * Ruby 2.3.0
214
+ * Ruby 2.2.0
215
+
216
+ # Contributing
217
+ Contributors are welcome and must adhere to the [Contributor covenant code of conduct](http://contributor-covenant.org/).
218
+
219
+ Please submit issues/bugs and patches on the [Misty repository](https://github.com/flystack/misty).
220
+
221
+ # Copyright
222
+ Copyright © 2007 Free Software Foundation, Inc. See [LICENSE](LICENSE.md) for details.
@@ -0,0 +1,8 @@
1
+ require 'logger'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'time'
5
+ require 'uri'
6
+ require 'misty/misty'
7
+ require 'misty/autoload'
8
+ require 'misty/cloud'
@@ -0,0 +1,60 @@
1
+ module Misty
2
+ class Auth
3
+ class AuthenticationError < StandardError; end
4
+ class CatalogError < StandardError; end
5
+ class TokenError < StandardError; end
6
+
7
+ class ExpiryError < RuntimeError; end
8
+ class CredentialsError < RuntimeError; end
9
+ class InitError < RuntimeError; end
10
+ class URLError < RuntimeError; end
11
+
12
+ def self.factory(options = {})
13
+ if options[:project]
14
+ return Misty::AuthV3.new(options)
15
+ elsif options[:tenant]
16
+ return Misty::AuthV2.new(options)
17
+ else
18
+ raise CredentialsError, "Cannot identify version from credentials"
19
+ end
20
+ end
21
+
22
+ attr_reader :catalog
23
+
24
+ def initialize(options)
25
+ raise CredentialsError unless credentials_valid?(options)
26
+ @credentials = scoped_credentials(options)
27
+ raise URLError, "No URL provided" unless options[:url] && !options[:url].empty?
28
+ @uri = URI.parse(options[:url])
29
+ @token = nil
30
+ setup(authenticate)
31
+ raise CatalogError, "No catalog provided during authentication" if @catalog.empty?
32
+ end
33
+
34
+ def authenticate
35
+ http = Net::HTTP.new(@uri.host, @uri.port)
36
+ response = http.post(self.class.path, @credentials.to_json, Misty::HEADER_JSON)
37
+ raise AuthenticationError, "Response code=#{response.code}, Msg=#{response.msg}" unless response.code =~ /200|201/
38
+ response
39
+ end
40
+
41
+ def expired?
42
+ raise ExpiryError, "Missing token expiration data" if @expires.nil? || @expires.empty?
43
+ Time.parse(@expires) < Time.now.utc
44
+ end
45
+
46
+ def get_endpoint(service_names, region, interface)
47
+ @catalog.each do |catalog|
48
+ if service_names.include? catalog["type"]
49
+ return catalog_endpoints(catalog["endpoints"], region, interface)
50
+ end
51
+ end
52
+ raise CatalogError, "No service found with either #{service_names} name, region #{region}, interface #{interface}"
53
+ end
54
+
55
+ def get_token
56
+ authenticate if expired?
57
+ @token
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,46 @@
1
+ require 'misty/auth'
2
+
3
+ module Misty
4
+ class AuthV2 < Misty::Auth
5
+ def self.path
6
+ "/v2.0/tokens"
7
+ end
8
+
9
+ def catalog_endpoints(endpoints, region, interface)
10
+ endpoints.each do |endpoint|
11
+ if endpoint["region"] == region && endpoint["#{interface}URL"]
12
+ return endpoint["#{interface}URL"]
13
+ end
14
+ end
15
+ end
16
+
17
+ def credentials_valid?(creds)
18
+ true if creds[:user] && creds[:password] && creds[:tenant]
19
+ end
20
+
21
+ def get_endpoint_url(endpoints, region, interface)
22
+ endpoint = endpoints.select { |ep| !ep[interface].empty? }
23
+ raise CatalogError, "No endpoint available for region '#{region}' and interface '#{interface}'" unless endpoint
24
+ endpoint[0][interface]
25
+ end
26
+
27
+ def setup(response)
28
+ payload = JSON.load(response.body)
29
+ @token = payload["access"]["token"]["id"]
30
+ @catalog = payload["access"]["serviceCatalog"]
31
+ @expires = payload["access"]["token"]["expires"]
32
+ end
33
+
34
+ def scoped_credentials(creds)
35
+ {
36
+ "auth": {
37
+ "passwordCredentials": {
38
+ "username": creds[:user],
39
+ "password": creds[:password]
40
+ },
41
+ "tenantName": creds[:tenant]
42
+ }
43
+ }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,58 @@
1
+ require 'misty/auth'
2
+
3
+ module Misty
4
+ class AuthV3 < Misty::Auth
5
+ def self.path
6
+ "/v3/auth/tokens"
7
+ end
8
+
9
+ def catalog_endpoints(endpoints, region, interface)
10
+ endpoints.each do |endpoint|
11
+ if endpoint["region_id"] == region && endpoint["interface"] == interface
12
+ return endpoint["url"]
13
+ end
14
+ end
15
+ end
16
+
17
+ def credentials_valid?(creds)
18
+ true if creds[:user] && creds[:password] && creds[:project]
19
+ end
20
+
21
+ def get_endpoint_url(endpoints, region, interface)
22
+ endpoint = endpoints.select { |ep| ep["region_id"] == region && ep["interface"] == interface }
23
+ raise CatalogError, "No endpoint available for region '#{region}' and interface '#{interface}'" unless endpoint
24
+ endpoint[0]["url"]
25
+ end
26
+
27
+ def setup(response)
28
+ payload = JSON.load(response.body)
29
+ @token = response["x-subject-token"]
30
+ @catalog = payload["token"]["catalog"]
31
+ @expires = payload["token"]["expires_at"]
32
+ end
33
+
34
+ def scoped_credentials(creds)
35
+ creds[:domain] ||= "default"
36
+ {
37
+ "auth": {
38
+ "identity": {
39
+ "methods": ["password"],
40
+ "password": {
41
+ "user": {
42
+ "name": creds[:user],
43
+ "domain": { "id": "default" },
44
+ "password": creds[:password]
45
+ }
46
+ }
47
+ },
48
+ "scope": {
49
+ "project": {
50
+ "name": creds[:project],
51
+ "domain": { "id": creds[:domain] }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,82 @@
1
+ module Misty
2
+ module Openstack
3
+ module Aodh
4
+ autoload :V2, "misty/openstack/aodh/v2"
5
+ end
6
+
7
+ module Ceilometer
8
+ autoload :V2, "misty/openstack/ceilometer/v2"
9
+ end
10
+
11
+ module Cinder
12
+ autoload :V1, "misty/openstack/cinder/v1"
13
+ autoload :V3, "misty/openstack/cinder/v3"
14
+ end
15
+
16
+ module Designate
17
+ autoload :V2, "misty/openstack/designate/v2"
18
+ end
19
+
20
+ module Glance
21
+ autoload :V1, "misty/openstack/glance/v1"
22
+ autoload :V2, "misty/openstack/glance/v2"
23
+ end
24
+
25
+ module Heat
26
+ autoload :V1, "misty/openstack/heat/v1"
27
+ end
28
+
29
+ module Ironic
30
+ autoload :V1, "misty/openstack/ironic/v1"
31
+ end
32
+
33
+ module Karbor
34
+ autoload :V1, "misty/openstack/karbor/v1"
35
+ end
36
+
37
+ module Keystone
38
+ autoload :V3, "misty/openstack/keystone/v3"
39
+ autoload :V2_0, "misty/openstack/keystone/v2_0"
40
+ end
41
+
42
+ module Magnum
43
+ autoload :V1, "misty/openstack/magnum/v1"
44
+ end
45
+
46
+ module Manila
47
+ autoload :V2, "misty/openstack/manila/v2"
48
+ end
49
+
50
+ module Neutron
51
+ autoload :V2_0, "misty/openstack/neutron/v2_0"
52
+ end
53
+
54
+ module Nova
55
+ autoload :V2_1, "misty/openstack/nova/v2_1"
56
+ end
57
+
58
+ module Sahara
59
+ autoload :V1_1, "misty/openstack/sahara/v1_1"
60
+ end
61
+
62
+ module Searchlight
63
+ autoload :V1, "misty/openstack/searchlight/v1"
64
+ end
65
+
66
+ module Senlin
67
+ autoload :V1, "misty/openstack/senlin/v1"
68
+ end
69
+
70
+ module Swift
71
+ autoload :V1, "misty/openstack/swift/v1"
72
+ end
73
+
74
+ module Trove
75
+ autoload :V1_0, "misty/openstack/trove/v1_0"
76
+ end
77
+
78
+ module Zaqar
79
+ autoload :V2, "misty/openstack/zaqar/v2"
80
+ end
81
+ end
82
+ end