miasma-google 0.1.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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e2d2cab6950ca96ad4fb8c6a3d190bbb3a43dab3
4
+ data.tar.gz: 25e2c4132fc3a15959b1ea3fc154c0bbe84f8dc2
5
+ SHA512:
6
+ metadata.gz: 80b7edcbb1da6434ed0fc57b35c16d9b6b5b75066255c4142a2f2582939bfc4522bbc218a7a0595e842b07f296c5fb2529372cd49da76c744d84b43758ff37dd
7
+ data.tar.gz: e87bde17346316b5e81f2dcf2b952f899a58eb31f21746dd71918971d3a151b74f51937ee46f28494c12472d9d600d46535bf60dee33bc26a7b7eb33005ff48d
@@ -0,0 +1,2 @@
1
+ # v0.1.0
2
+ * Initial release with Orchestration support
data/LICENSE ADDED
File without changes
@@ -0,0 +1,46 @@
1
+ # Miasma Google
2
+
3
+ Google API plugin for the miasma cloud library
4
+
5
+ ## Supported credential attributes:
6
+
7
+ Supported attributes used in the credentials section of API
8
+ configurations:
9
+
10
+ ```ruby
11
+ Miasma.api(
12
+ :type => :orchestration,
13
+ :provider => :google,
14
+ :credentials => {
15
+ ...
16
+ }
17
+ )
18
+ ```
19
+
20
+ ### Credential attributes
21
+
22
+ ` `google_project` - ID of the google project to use
23
+ * `google_service_account_email` - Email address for service account
24
+ * `google_service_account_private_key` - Path to private key for service account
25
+ * `google_auth_scope` - Scope requested for user (default: `'cloud-platform'`)
26
+ * `google_auth_base` - URL endpoint for authorization (default: `'https://www.googleapis.com/auth'`)
27
+ * `google_assertion_target` - URL for permission assertion (default: `'https://www.googleapis.com/oauth2/v4/token'`)
28
+ * `google_assertion_expiry` - Number of seconds token is valid (default: `120`)
29
+ * `google_api_base_endpoint` - URL for requests (default: `'https://www.googleapis.com'`)
30
+
31
+ ## Current support matrix
32
+
33
+ |Model |Create|Read|Update|Delete|
34
+ |--------------|------|----|------|------|
35
+ |AutoScale | | | | |
36
+ |BlockStorage | | | | |
37
+ |Compute | | | | |
38
+ |DNS | | | | |
39
+ |LoadBalancer | | | | |
40
+ |Network | | | | |
41
+ |Orchestration | X | X | X | X |
42
+ |Queues | | | | |
43
+ |Storage | | | | |
44
+
45
+ ## Info
46
+ * Repository: https://github.com/miasma-rb/miasma-google
@@ -0,0 +1,3 @@
1
+ module MiasmaGoogle
2
+ VERSION = Gem::Version.new('0.1.0')
3
+ end
@@ -0,0 +1,261 @@
1
+ require 'miasma'
2
+ require 'base64'
3
+ require 'digest/sha2'
4
+ require 'openssl'
5
+
6
+ module Miasma
7
+
8
+ module Contrib
9
+
10
+ module Google
11
+
12
+ # Base signature class
13
+ class Signature
14
+
15
+ # @return [String] algorithm of signature
16
+ attr_reader :algorithm
17
+ # @return [String] format of signature
18
+ attr_reader :format
19
+ # @return [Smash] signature claims
20
+ attr_reader :claims
21
+
22
+ # Create a new signature
23
+ #
24
+ # @param [String, Symbol] algorithm used for signature
25
+ # @param [String, Symbol] format of signature
26
+ # @param claims [Hash] request claims
27
+ # @return [self]
28
+ def initialize(algo, fmt, clms)
29
+ @algorithm = algo
30
+ @format = fmt
31
+ @claims = clms.to_smash
32
+ end
33
+
34
+ # Generate signature
35
+ #
36
+ # @return [String]
37
+ def generate
38
+ raise NotImplementedError
39
+ end
40
+
41
+ # JSON Web Token signature
42
+ class Jwt < Signature
43
+
44
+ # Required items within claims
45
+ REQUIRED_CLAIMS = [
46
+ :iss, # email address of service account
47
+ :scope, # space-delimited list of permissions requested
48
+ :aud, # intended target of assertion
49
+ :exp, # expiration time of assertion
50
+ :iat # time assertion was issued
51
+ ]
52
+
53
+ # Create a new JWT signature instance
54
+ #
55
+ # @param private_key_path [String] private signing key path
56
+ # @param claims [Hash] request claims
57
+ # @return [self]
58
+ def initialize(private_key_path, i_claims)
59
+ super('RS256', 'JWT', i_claims)
60
+ claims[:iat] ||= Time.now.to_i
61
+ claims[:exp] ||= Time.now.to_i + 120
62
+ @private_key = private_key_path
63
+ validate_claims!
64
+ validate_key!
65
+ end
66
+
67
+ # Generate signature
68
+ #
69
+ # @return [String]
70
+ def generate
71
+ "#{encoded_header}.#{encoded_claims}.#{encoded_signature}"
72
+ end
73
+
74
+ # @return [String] encoded header
75
+ def encoded_header
76
+ Base64.urlsafe_encode64(header.to_json)
77
+ end
78
+
79
+ # @return [String] header
80
+ def header
81
+ Smash.new(
82
+ :alg => algorithm,
83
+ :typ => format
84
+ )
85
+ end
86
+
87
+ # @return [String] encoded claims set
88
+ def encoded_claims
89
+ t_claims = claims.to_smash
90
+ if(t_claims.key?(:scope))
91
+ t_claims[:scope] = [t_claims[:scope]].flatten.compact.join(' ')
92
+ end
93
+ Base64.urlsafe_encode64(t_claims.to_json)
94
+ end
95
+
96
+ # @return [String] encoded signature
97
+ def encoded_signature
98
+ Base64.urlsafe_encode64(signature)
99
+ end
100
+
101
+ # @return [String] JWT signature
102
+ def signature
103
+ token = "#{encoded_header}.#{encoded_claims}"
104
+ hasher = OpenSSL::Digest::SHA256.new
105
+ author = OpenSSL::PKey::RSA.new(File.read(@private_key))
106
+ author.sign(hasher, token)
107
+ end
108
+
109
+ # Check for required claims and raise error if unset
110
+ #
111
+ # @return [TrueClass]
112
+ # @raises [KeyError]
113
+ def validate_claims!
114
+ REQUIRED_CLAIMS.each do |claim|
115
+ unless(claims.key?(claim))
116
+ raise KeyError.new "Missing required claim key `#{claim}`"
117
+ end
118
+ end
119
+ true
120
+ end
121
+
122
+ # Check that the private key exists, is readable, and is
123
+ def validate_key!
124
+ end
125
+
126
+ end
127
+
128
+ end
129
+
130
+ module ApiCommon
131
+
132
+ def self.included(klass)
133
+ klass.class_eval do
134
+ attribute :google_service_account_email, String, :required => true
135
+ attribute :google_service_account_private_key, String, :required => true
136
+ attribute :google_auth_scope, String, :required => true, :multiple => true, :default => 'cloud-platform'
137
+ attribute :google_auth_base, String, :default => 'https://www.googleapis.com/auth'
138
+ attribute :google_assertion_target, String, :required => true, :default => 'https://www.googleapis.com/oauth2/v4/token'
139
+ attribute :google_assertion_expiry, Integer, :required => true, :default => 120
140
+ attribute :google_project, String, :required => true
141
+ attribute :google_api_base_endpoint, String, :required => true, :default => 'https://www.googleapis.com'
142
+ end
143
+
144
+ klass.const_set(:TOKEN_GRANT_TYPE, 'urn:ietf:params:oauth:grant-type:jwt-bearer')
145
+ end
146
+
147
+ # @return [String]
148
+ def endpoint
149
+ point = google_api_base_endpoint.dup
150
+ if(self.class.const_defined?(:GOOGLE_SERVICE_PATH))
151
+ point << "/#{self.class.const_get(:GOOGLE_SERVICE_PATH)}"
152
+ end
153
+ if(self.class.const_defined?(:GOOGLE_SERVICE_PROJECT) && self.class.const_get(:GOOGLE_SERVICE_PROJECT))
154
+ point << "/projects/#{google_project}"
155
+ end
156
+ point
157
+ end
158
+
159
+ # Setup for API connections
160
+ def connect
161
+ @oauth_token_information = Smash.new
162
+ end
163
+
164
+ def oauth_token_information
165
+ @oauth_token_information
166
+ end
167
+
168
+ # @return [HTTP] connection for requests (forces headers)
169
+ def connection
170
+ super.headers(
171
+ 'Authorization' => "Bearer #{client_access_token}"
172
+ )
173
+ end
174
+
175
+ # @return [Contrib::Google::Signature::Jwt]
176
+ def signer
177
+ Contrib::Google::Signature::Jwt.new(
178
+ google_service_account_private_key,
179
+ :iss => google_service_account_email,
180
+ :scope => [google_auth_scope].flatten.compact.map{|scope|
181
+ "#{google_auth_base}/#{scope}"
182
+ },
183
+ :aud => google_assertion_target,
184
+ :exp => Time.now.to_i + google_assertion_expiry
185
+ )
186
+ end
187
+
188
+ # Request a new authentication token from the remote API
189
+ #
190
+ # @return [Smash] token information - :access_token, :token_type, :expires_in, :expires_on
191
+ def request_client_token
192
+ token_signer = signer
193
+ result = HTTP.post(
194
+ google_assertion_target,
195
+ :form => {
196
+ :grant_type => self.class.const_get(:TOKEN_GRANT_TYPE),
197
+ :assertion => token_signer.generate
198
+ }
199
+ )
200
+ unless(result.code == 200)
201
+ raise Miasma::Error::ApiError.new(
202
+ 'Request for client authentication token failed',
203
+ :response => result
204
+ )
205
+ end
206
+ @oauth_token_information = MultiJson.load(result.body.to_s).to_smash
207
+ @oauth_token_information[:expires_on] = Time.at(
208
+ @oauth_token_information[:expires_in] + token_signer.claims[:iat].to_i
209
+ )
210
+ @oauth_token_information
211
+ end
212
+
213
+ # @return [String] auth token
214
+ def client_access_token
215
+ request_client_token if access_token_expired?
216
+ oauth_token_information[:access_token]
217
+ end
218
+
219
+ # @return [TrueClass, FalseClass]
220
+ def access_token_expired?
221
+ if(oauth_token_information[:expires_on])
222
+ oauth_token_information[:expires_on] < Time.now
223
+ else
224
+ true
225
+ end
226
+ end
227
+
228
+ # When in debug mode, do not retry requests
229
+ #
230
+ # @return [TrueClass, FalseClass]
231
+ def retryable_allowed?(*_)
232
+ if(ENV['DEBUG'])
233
+ false
234
+ else
235
+ super
236
+ end
237
+ end
238
+
239
+ # Define when request should be retried
240
+ #
241
+ # @param exception [Exception]
242
+ # @return [TrueClass, FalseClass]
243
+ def perform_request_retry(exception)
244
+ if(exception.is_a?(Error::ApiError::RequestError))
245
+ exception.response.code >= 500
246
+ else
247
+ false
248
+ end
249
+ end
250
+
251
+ end
252
+ end
253
+ end
254
+
255
+ Models::Storage.autoload :Google, 'miasma/contrib/google/storage'
256
+ Models::Orchestration.autoload :Google, 'miasma/contrib/google/orchestration'
257
+
258
+ # Models::Compute.autoload :Google, 'misama/contrib/google/compute'
259
+ # Models::LoadBalancer.autoload :Google, 'misama/contrib/google/load_balancer'
260
+ # Models::AutoScale.autoload :Google, 'misama/contrib/google/auto_scale'
261
+ end
@@ -0,0 +1,338 @@
1
+ require 'securerandom'
2
+ require 'miasma'
3
+
4
+ module Miasma
5
+ module Models
6
+ class Orchestration
7
+ class Google < Orchestration
8
+
9
+ include Contrib::Google::ApiCommon
10
+
11
+ GOOGLE_SERVICE_PATH = '/deploymentmanager/v2'
12
+ GOOGLE_SERVICE_PROJECT = true
13
+
14
+ # Determine stack state based on last operation information
15
+ #
16
+ # @param operation [Hash]
17
+ # @option operation [String] :operationType
18
+ # @option operation [String] :status
19
+ # @return [Symbol]
20
+ def determine_state(operation)
21
+ prefix = case operation[:operationType]
22
+ when 'insert'
23
+ 'create'
24
+ when 'update'
25
+ 'update'
26
+ when 'delete'
27
+ 'delete'
28
+ end
29
+ suffix = case operation[:status]
30
+ when 'RUNNING', 'PENDING'
31
+ 'in_progress'
32
+ when 'DONE'
33
+ 'complete'
34
+ end
35
+ if(operation[:error])
36
+ suffix = 'failed'
37
+ end
38
+ if(prefix.nil? || suffix.nil?)
39
+ :unknown
40
+ else
41
+ "#{prefix}_#{suffix}".to_sym
42
+ end
43
+ end
44
+
45
+ # Create stack data hash from information
46
+ #
47
+ # @param info [Hash]
48
+ # @option info [String] :insertTime
49
+ # @option info [String] :description
50
+ # @option info [String] :name
51
+ # @option info [Hash] :operation
52
+ # @return [Hash]
53
+ def basic_stack_data_format(info)
54
+ info = info.to_smash
55
+ Smash.new(
56
+ :id => info[:id],
57
+ :created => Time.parse(info[:insertTime]),
58
+ :updated => Time.parse(info.fetch(:operation, :endTime, info.fetch(:operation, :startTime, info[:insertTime]))),
59
+ :description => info[:description],
60
+ :name => info[:name],
61
+ :state => determine_state(info.fetch(:operation, {})),
62
+ :status => determine_state(info.fetch(:operation, {})).to_s.split('_').map(&:capitalize).join(' '),
63
+ :custom => info
64
+ )
65
+ end
66
+
67
+ # @return [Array<Stack>]
68
+ def stack_all
69
+ result = request(
70
+ :path => 'global/deployments'
71
+ )
72
+ result.fetch(:body, :deployments, []).map do |item|
73
+ new_stack = Stack.new(self)
74
+ new_stack.load_data(basic_stack_data_format(item)).valid_state
75
+ end
76
+ end
77
+
78
+ # Save stack state
79
+ #
80
+ # @param stack [Stack]
81
+ # @return [Stack]
82
+ def stack_save(stack)
83
+ unless(stack.persisted?)
84
+ result = request(
85
+ :path => 'global/deployments',
86
+ :method => :post,
87
+ :json => {
88
+ :name => stack.name,
89
+ :target => template_data_unformat(stack.template)
90
+ }
91
+ )
92
+ else
93
+ result = request(
94
+ :path => "global/deployments/#{stack.name}",
95
+ :method => :put,
96
+ :json => {
97
+ :name => stack.name,
98
+ :target => template_data_unformat(stack.template),
99
+ :fingerprint => stack.custom[:fingerprint]
100
+ }
101
+ )
102
+ end
103
+ stack.id = result.get(:body, :id)
104
+ stack.valid_state
105
+ stack.reload
106
+ end
107
+
108
+ # Fetch the stack template
109
+ #
110
+ # @param stack [Stack]
111
+ # @return [Hash]
112
+ def stack_template_load(stack)
113
+ if(stack.persisted?)
114
+ result = request(
115
+ :endpoint => stack.custom.fetch(:manifest, stack.custom.get(:update, :manifest))
116
+ )
117
+ cache_template = stack.template = template_data_format(result[:body])
118
+ stack.custom = stack.custom.merge(result[:body])
119
+ if(stack.custom['expandedConfig'])
120
+ stack.custom['expandedConfig'] = YAML.load(stack.custom['expandedConfig']).to_smash
121
+ end
122
+ if(stack.custom['layout'])
123
+ stack.custom['layout'] = YAML.load(stack.custom['layout']).to_smash
124
+ end
125
+ stack.valid_state
126
+ cache_template
127
+ else
128
+ Smash.new
129
+ end
130
+ end
131
+
132
+ # Pack template data for shipping to API
133
+ #
134
+ # @param data [Hash]
135
+ # @option data [Hash] :config
136
+ # @option data [Array<Hash>] :imports
137
+ # @return [Hash]
138
+ def template_data_unformat(data)
139
+ Hash.new.tap do |result|
140
+ if(v = data.to_smash.get(:config, :content))
141
+ result[:config] = {
142
+ :content => yamlize(v)
143
+ }
144
+ end
145
+ if(data[:imports])
146
+ result[:imports] = data[:imports].map do |item|
147
+ Smash.new(
148
+ :name => item['name'],
149
+ :content => yamlize(item['content'])
150
+ )
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ # Convert value to YAML if not string
157
+ #
158
+ # @param value [Object]
159
+ # @return [String, Object]
160
+ def yamlize(value)
161
+ unless(value.is_a?(String))
162
+ if(value.is_a?(Hash) && value.respond_to?(:to_hash))
163
+ value = value.to_hash
164
+ end
165
+ value.to_yaml(:header => true)
166
+ else
167
+ value
168
+ end
169
+ end
170
+
171
+ # Unpack received template data for local model instance
172
+ #
173
+ # @param data [Hash]
174
+ # @option data [Hash] :config
175
+ # @option data [Array<Hash>] :imports
176
+ # @return [Hash]
177
+ def template_data_format(data)
178
+ data = data.to_smash
179
+ Smash.new.tap do |result|
180
+ result[:config] = data.fetch(:config, Smash.new)
181
+ result[:imports] = data.fetch(:imports, []).map do |item|
182
+ begin
183
+ Smash.new(
184
+ :name => item[:name],
185
+ :content => YAML.load(item[:content])
186
+ )
187
+ rescue
188
+ item
189
+ end
190
+ end
191
+ if(result.get(:config, :content))
192
+ result[:config][:content] = YAML.load(result[:config][:content]) || Smash.new
193
+ else
194
+ result[:config][:content] = Smash.new
195
+ end
196
+ end
197
+ end
198
+
199
+ # Reload the stack data
200
+ #
201
+ # @param stack [Stack]
202
+ # @return [Stack]
203
+ def stack_reload(stack)
204
+ if(stack.persisted?)
205
+ result = request(
206
+ :path => "global/deployments/#{stack.name}"
207
+ )
208
+ deploy = result[:body]
209
+ stack.load_data(basic_stack_data_format(deploy)).valid_state
210
+ stack_template_load(stack)
211
+ set_outputs_if_available(stack)
212
+ end
213
+ stack
214
+ end
215
+
216
+ # Set outputs into stack instance
217
+ #
218
+ # @param stack [Stack]
219
+ # @return [TrueClass, FalseClass]
220
+ def set_outputs_if_available(stack)
221
+ outputs = extract_outputs(stack.custom.fetch(:layout, {}))
222
+ unless(outputs.empty?)
223
+ stack.outputs = outputs
224
+ stack.valid_state
225
+ true
226
+ else
227
+ false
228
+ end
229
+ end
230
+
231
+ # Extract outputs from stack hash
232
+ #
233
+ # @param stack_hash [Hash]
234
+ # @return [Array<Hash>]
235
+ def extract_outputs(stack_hash)
236
+ outputs = []
237
+ if(stack_hash[:outputs])
238
+ outputs += stack_hash[:outputs].map do |output|
239
+ Smash.new(:key => output[:name], :value => output[:finalValue])
240
+ end
241
+ end
242
+ stack_hash.fetch(:resources, []).each do |resource|
243
+ outputs += extract_outputs(resource)
244
+ end
245
+ outputs
246
+ end
247
+
248
+ # Delete stack
249
+ #
250
+ # @param stack [Stack]
251
+ # @return [TrueClass, FalseClass]
252
+ def stack_destroy(stack)
253
+ if(stack.persisted?)
254
+ request(
255
+ :path => "global/deployments/#{stack.name}",
256
+ :method => :delete
257
+ )
258
+ true
259
+ else
260
+ false
261
+ end
262
+ end
263
+
264
+ # Fetch all events
265
+ #
266
+ # @param stack [Stack]
267
+ # @return [Array<Stack::Event>]
268
+ def event_all(stack, evt_id=nil)
269
+ result = request(
270
+ :path => 'global/operations',
271
+ :params => {
272
+ :filter => "targetId eq #{stack.id}"
273
+ }
274
+ )
275
+ result.fetch(:body, :operations, []).map do |event|
276
+ status_msg = [
277
+ event[:statusMessage],
278
+ *event.fetch(:error, :errors, []).map{|e| e[:message]}
279
+ ].compact.join(' -- ')
280
+ Stack::Event.new(
281
+ stack,
282
+ :id => event[:id],
283
+ :resource_id => event[:targetId],
284
+ :resource_name => stack.name,
285
+ :resource_logical_id => stack.name,
286
+ :resource_state => determine_state(event),
287
+ :resource_status => determine_state(event).to_s.split('_').map(&:capitalize).join(' '),
288
+ :resource_status_reason => "#{event[:status]} - #{event[:progress]}% complete #{status_msg}",
289
+ :time => Time.parse(event.fetch(:startTime, event[:insertTime]))
290
+ ).valid_state
291
+ end
292
+ end
293
+
294
+ # Fetch all stack resources
295
+ #
296
+ # @param stack [Stack]
297
+ # @return [Array<Stack::Resource>]
298
+ # @todo Add status reason extraction
299
+ def resource_all(stack)
300
+ request(
301
+ :path => "global/deployments/#{stack.name}/resources"
302
+ ).fetch('body', 'resources', []).map do |resource|
303
+ Stack::Resource.new(stack,
304
+ :id => resource[:id],
305
+ :type => resource[:type],
306
+ :name => resource[:name],
307
+ :logical_id => resource[:name],
308
+ :created => Time.parse(resource[:insertTime]),
309
+ :updated => resource[:updateTime] ? Time.parse(resource[:updateTime]) : nil,
310
+ :state => :create_complete,
311
+ :status => 'OK',
312
+ :status_reason => resource.fetch(:warnings, []).map{|w| w[:message]}.join(' ')
313
+ ).valid_state
314
+ end
315
+ end
316
+
317
+ # Reload resource data
318
+ #
319
+ # @param resource [Stack::Resource]
320
+ # @return [Stack::Resource]
321
+ def resource_reload(resource)
322
+ resource.stack.resources.reload
323
+ resource.stack.resources.get(resource.id)
324
+ end
325
+
326
+ # Reload event data
327
+ #
328
+ # @param event [Stack::Event]
329
+ # @return event [Stack::Event]
330
+ def event_reload(event)
331
+ event.stack.events.reload
332
+ event.stack.events.get(event.id)
333
+ end
334
+
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,21 @@
1
+ $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__)) + '/lib/'
2
+ require 'miasma-google/version'
3
+ Gem::Specification.new do |s|
4
+ s.name = 'miasma-google'
5
+ s.version = MiasmaGoogle::VERSION.version
6
+ s.summary = 'Smoggy Google API'
7
+ s.author = 'Chris Roberts'
8
+ s.email = 'code@chrisroberts.org'
9
+ s.homepage = 'https://github.com/miasma-rb/miasma-google'
10
+ s.description = 'Smoggy Google API'
11
+ s.license = 'Apache 2.0'
12
+ s.require_path = 'lib'
13
+ s.add_runtime_dependency 'miasma', '>= 0.2.12'
14
+ s.add_development_dependency 'pry'
15
+ s.add_development_dependency 'minitest'
16
+ s.add_development_dependency 'vcr'
17
+ s.add_development_dependency 'webmock'
18
+ s.add_development_dependency 'psych', '>= 2.0.8'
19
+ s.add_runtime_dependency 'mime-types'
20
+ s.files = Dir['lib/**/*'] + %w(miasma-google.gemspec README.md CHANGELOG.md LICENSE)
21
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: miasma-google
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chris Roberts
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-04-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: miasma
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.12
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.12
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: vcr
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: psych
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 2.0.8
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 2.0.8
97
+ - !ruby/object:Gem::Dependency
98
+ name: mime-types
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description: Smoggy Google API
112
+ email: code@chrisroberts.org
113
+ executables: []
114
+ extensions: []
115
+ extra_rdoc_files: []
116
+ files:
117
+ - CHANGELOG.md
118
+ - LICENSE
119
+ - README.md
120
+ - lib/miasma-google/version.rb
121
+ - lib/miasma/contrib/google.rb
122
+ - lib/miasma/contrib/google/orchestration.rb
123
+ - miasma-google.gemspec
124
+ homepage: https://github.com/miasma-rb/miasma-google
125
+ licenses:
126
+ - Apache 2.0
127
+ metadata: {}
128
+ post_install_message:
129
+ rdoc_options: []
130
+ require_paths:
131
+ - lib
132
+ required_ruby_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ requirements: []
143
+ rubyforge_project:
144
+ rubygems_version: 2.4.8
145
+ signing_key:
146
+ specification_version: 4
147
+ summary: Smoggy Google API
148
+ test_files: []
149
+ has_rdoc: