collins_state 0.2.10

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ source :rubygems
2
+
3
+ Encoding.default_external = Encoding::UTF_8
4
+ gem 'collins_client', '~> 0.2.7'
5
+ gem 'escape', '~> 0.0.4'
6
+
7
+ group :development do
8
+ gem "rspec", "~> 2.10.0"
9
+ gem "yard", "~> 0.8"
10
+ gem 'redcarpet'
11
+ gem 'webmock'
12
+ gem "bundler", "~> 1.1.4"
13
+ gem "jeweler", "~> 1.8.4"
14
+ gem 'simplecov'
15
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,55 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ addressable (2.3.2)
5
+ collins_client (0.2.7)
6
+ httparty (~> 0.8.3)
7
+ crack (0.3.1)
8
+ diff-lcs (1.1.3)
9
+ escape (0.0.4)
10
+ git (1.2.5)
11
+ httparty (0.8.3)
12
+ multi_json (~> 1.0)
13
+ multi_xml
14
+ jeweler (1.8.4)
15
+ bundler (~> 1.0)
16
+ git (>= 1.2.5)
17
+ rake
18
+ rdoc
19
+ json (1.7.5)
20
+ multi_json (1.3.6)
21
+ multi_xml (0.5.1)
22
+ rake (0.9.2.2)
23
+ rdoc (3.12)
24
+ json (~> 1.4)
25
+ redcarpet (2.2.2)
26
+ rspec (2.10.0)
27
+ rspec-core (~> 2.10.0)
28
+ rspec-expectations (~> 2.10.0)
29
+ rspec-mocks (~> 2.10.0)
30
+ rspec-core (2.10.1)
31
+ rspec-expectations (2.10.0)
32
+ diff-lcs (~> 1.1.3)
33
+ rspec-mocks (2.10.1)
34
+ simplecov (0.7.1)
35
+ multi_json (~> 1.0)
36
+ simplecov-html (~> 0.7.1)
37
+ simplecov-html (0.7.1)
38
+ webmock (1.8.11)
39
+ addressable (>= 2.2.7)
40
+ crack (>= 0.1.7)
41
+ yard (0.8.3)
42
+
43
+ PLATFORMS
44
+ ruby
45
+
46
+ DEPENDENCIES
47
+ bundler (~> 1.1.4)
48
+ collins_client (~> 0.2.7)
49
+ escape (~> 0.0.4)
50
+ jeweler (~> 1.8.4)
51
+ redcarpet
52
+ rspec (~> 2.10.0)
53
+ simplecov
54
+ webmock
55
+ yard (~> 0.8)
data/README.md ADDED
@@ -0,0 +1,6 @@
1
+ # collins_state
2
+
3
+ Collins state is a simple state management framework built on top of collins.
4
+
5
+ Take a look at some of the specs or the provisioning workflow for some sample
6
+ code.
data/Rakefile ADDED
@@ -0,0 +1,67 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ jeweler = Jeweler::Tasks.new do |gem|
16
+ gem.name = "collins_state"
17
+ gem.homepage = "https://github.com/tumblr/collins/tree/master/support/ruby/collins-state"
18
+ gem.license = "APL 2.0"
19
+ gem.summary = %Q{Collins based state management}
20
+ gem.description = %Q{Provides basic framework for managing stateful processes with collins}
21
+ gem.email = "bmatheny@tumblr.com"
22
+ gem.authors = ["Blake Matheny"]
23
+ gem.files.exclude "spec/**/*"
24
+ gem.files.exclude '.gitignore'
25
+ gem.files.exclude '.rspec'
26
+ gem.files.exclude '.rvmrc'
27
+ gem.add_runtime_dependency 'collins_client', '~> 0.2.7'
28
+ gem.add_runtime_dependency 'escape', '~> 0.0.4'
29
+ end
30
+
31
+ task :help do
32
+ puts("rake -T # See available rake tasks")
33
+ puts("rake publish # generate gemspec, build it, push it to repo")
34
+ puts("rake version:bump:patch # Bump patch number")
35
+ puts("rake all # bump patch and publish")
36
+ puts("rake # Run tests")
37
+ end
38
+
39
+ task :publish => [:gemspec, :build] do
40
+ package_abs = jeweler.jeweler.gemspec_helper.gem_path
41
+ package_name = File.basename(package_abs)
42
+
43
+ ["repo.tumblr.net","repo.ewr01.tumblr.net"].each do |host|
44
+ puts("Copying #{package_abs} to #{host} and installing, you may be prompted for your password")
45
+ system "scp #{package_abs} #{host}:"
46
+ system "ssh -t #{host} 'sudo tumblr_gem install #{package_name}'"
47
+ end
48
+ end
49
+
50
+ task :all => ["version:bump:patch", :publish] do
51
+ puts("Done!")
52
+ end
53
+
54
+ require 'rspec/core'
55
+ require 'rspec/core/rake_task'
56
+ RSpec::Core::RakeTask.new(:spec) do |spec|
57
+ spec.fail_on_error = false
58
+ spec.pattern = FileList['spec/**/*_spec.rb']
59
+ end
60
+
61
+ task :default => :spec
62
+
63
+ require 'yard'
64
+ YARD::Rake::YardocTask.new do |t|
65
+ t.files = ['lib/**/*.rb']
66
+ t.options = ['--markup', 'markdown']
67
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.10
@@ -0,0 +1,53 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "collins_state"
8
+ s.version = "0.2.10"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Blake Matheny"]
12
+ s.date = "2012-10-31"
13
+ s.description = "Provides basic framework for managing stateful processes with collins"
14
+ s.email = "bmatheny@tumblr.com"
15
+ s.extra_rdoc_files = [
16
+ "README.md"
17
+ ]
18
+ s.files = [
19
+ "Gemfile",
20
+ "Gemfile.lock",
21
+ "README.md",
22
+ "Rakefile",
23
+ "VERSION",
24
+ "collins_state.gemspec",
25
+ "lib/collins/persistent_state.rb",
26
+ "lib/collins/state/mixin.rb",
27
+ "lib/collins/state/mixin_class_methods.rb",
28
+ "lib/collins/state/specification.rb",
29
+ "lib/collins/workflows/provisioning_workflow.rb",
30
+ "lib/collins_state.rb"
31
+ ]
32
+ s.homepage = "https://github.com/tumblr/collins/tree/master/support/ruby/collins-state"
33
+ s.licenses = ["APL 2.0"]
34
+ s.require_paths = ["lib"]
35
+ s.rubygems_version = "1.8.24"
36
+ s.summary = "Collins based state management"
37
+
38
+ if s.respond_to? :specification_version then
39
+ s.specification_version = 3
40
+
41
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
42
+ s.add_runtime_dependency(%q<collins_client>, ["~> 0.2.7"])
43
+ s.add_runtime_dependency(%q<escape>, ["~> 0.0.4"])
44
+ else
45
+ s.add_dependency(%q<collins_client>, ["~> 0.2.7"])
46
+ s.add_dependency(%q<escape>, ["~> 0.0.4"])
47
+ end
48
+ else
49
+ s.add_dependency(%q<collins_client>, ["~> 0.2.7"])
50
+ s.add_dependency(%q<escape>, ["~> 0.0.4"])
51
+ end
52
+ end
53
+
@@ -0,0 +1,108 @@
1
+ require 'collins/state/mixin'
2
+ require 'escape'
3
+
4
+ module Collins
5
+
6
+ # Provides state management via collins tags
7
+ class PersistentState
8
+
9
+ include ::Collins::State::Mixin
10
+ include ::Collins::Util
11
+
12
+ attr_reader :collins_client, :path, :exec_type, :logger
13
+
14
+ def initialize collins_client, options = {}
15
+ @collins_client = collins_client
16
+ @exec_type = :client
17
+ @logger = get_logger({:logger => collins_client.logger}.merge(options).merge(:progname => 'Collins_PersistentState'))
18
+ end
19
+
20
+ def run
21
+ self.class.managed_state(collins_client)
22
+ self
23
+ end
24
+
25
+ # @deprecated Use {#use_netcat} instead. Replace in 0.3.0
26
+ def use_curl path = nil
27
+ use_netcat(path)
28
+ end
29
+
30
+ def use_client
31
+ @path = nil
32
+ @exec_type = :client
33
+ self
34
+ end
35
+
36
+ def use_netcat path = nil
37
+ @path = Collins::Option(path).get_or_else("nc")
38
+ @exec_type = :netcat
39
+ self
40
+ end
41
+
42
+ # @override update_asset(asset, key, spec)
43
+ def update_asset asset, key, spec
44
+ username = collins_client.username
45
+ password = collins_client.password
46
+ host = collins_client.host
47
+ tag = ::Collins::Util.get_asset_or_tag(asset).tag
48
+ case @exec_type
49
+ when :netcat
50
+ netcat_command = [path, '-i', '1'] + get_hostname_port(host)
51
+ timestamp_padding, json = format_spec_for_netcat spec
52
+ body = "attribute=#{key};#{json}"
53
+ length = body.size + timestamp_padding
54
+ request = [request_line(tag)] + request_headers(username, password, length)
55
+ request_string = request.join("\\r\\n") + "\\r\\n\\r\\n" + body
56
+ current_time = 'TIMESTAMP=$(' + get_time_cmds(host).join(' | ') + ')'
57
+ args = ['printf', request_string]
58
+ "#{current_time}\n" + Escape.shell_command(args) + ' $TIMESTAMP | ' + Escape.shell_command(netcat_command)
59
+ else
60
+ super(asset, key, spec)
61
+ end
62
+ end
63
+
64
+ def get_time_cmds host
65
+ hostname, port = get_hostname_port host
66
+ get_cmd = %q{printf 'GET /api/timestamp HTTP/1.0\r\n\r\n'}
67
+ nc = sprintf('%s -i 1 %s %d', path, hostname, port)
68
+ only_time = %q{grep -e '^[0-9]'}
69
+ last_line = %q{tail -n 1}
70
+ [get_cmd, nc, only_time, last_line]
71
+ end
72
+
73
+ def request_line tag
74
+ "POST /api/asset/#{tag} HTTP/1.0"
75
+ end
76
+
77
+ def request_headers username, password, length
78
+ [
79
+ "User-Agent: collins_state",
80
+ auth_header(username, password),
81
+ "Content-Type: application/x-www-form-urlencoded",
82
+ "Content-Length: #{length}",
83
+ "Connection: Close"
84
+ ]
85
+ end
86
+
87
+ def auth_header username, password
88
+ auth = Base64.strict_encode64("#{username}:#{password}")
89
+ "Authorization: Basic #{auth}"
90
+ end
91
+
92
+ # @return [Array<Fixnum,String>] padding for format string and json to use
93
+ def format_spec_for_netcat spec
94
+ expected_timestamp_size = Time.now.utc.to_i.to_s.size
95
+ spec.timestamp = '%s'
96
+ actual_timestamp_size = spec.timestamp.to_s.size
97
+ timestamp_padding = (expected_timestamp_size - actual_timestamp_size).abs
98
+ json = spec.to_json
99
+ [timestamp_padding, json]
100
+ end
101
+ # @return [Array<String,String>] hostname and port
102
+ def get_hostname_port host
103
+ host = URI.parse(host)
104
+ [host.host, host.port.to_s]
105
+ end
106
+ end
107
+
108
+ end
@@ -0,0 +1,435 @@
1
+ require 'collins/state/mixin_class_methods'
2
+ require 'collins/state/specification'
3
+ require 'json'
4
+
5
+ module Collins; module State; module Mixin
6
+
7
+ class << self
8
+ # Classes that include Mixin will also be extended by ClassMethods
9
+ def included(base)
10
+ base.extend Collins::State::Mixin::ClassMethods
11
+ end
12
+ end
13
+
14
+ # @abstract Classes mixing this in must supply a collins client
15
+ # @return [Collins::Client] collins client
16
+ # @raise [NotImplementedError] if not specified
17
+ def collins_client
18
+ raise NotImplementedError.new("no collins client available")
19
+ end
20
+
21
+ # @abstract Classes mixing this in must supply a logger
22
+ # @return [Logger] logger instance
23
+ # @raise [NotImplementedError] if not specified
24
+ def logger
25
+ raise NotImplementedError.new("no logger available")
26
+ end
27
+
28
+ # The attribute name used for storing the sate value
29
+ # @note we append _json to the managed_state_name since it will serialize this way
30
+ # @return [String] the key to use for storing the state value on an asset
31
+ def attribute_name
32
+ self.class.managed_state_name.to_s + "_json"
33
+ end
34
+
35
+ # Has the specified asset state expired?
36
+ #
37
+ # Read the state from the specified asset, and determine whether the state is expired yet or not.
38
+ # The timestamp + expiration is compared to now.
39
+ #
40
+ # @note This method will return true if no specification is associated with the asset
41
+ # @param [Collins::Asset] asset The asset to look at
42
+ # @return [Boolean] True if the specification has expired, false otherwise
43
+ def expired? asset
44
+ specification_expired?(state_specification(asset))
45
+ end
46
+
47
+ # Whether we are done processing or not
48
+ # @param [Collins::Asset] asset
49
+ # @return [Boolean] whether asset is in done state or not
50
+ def finished? asset
51
+ state_specification(asset).to_option.map do |spec|
52
+ specification_expired?(spec) && event(spec.name)[:terminus]
53
+ end.get_or_else(false)
54
+ end
55
+
56
+ # The things that would be done if the asset was transitioned
57
+ # @param [Collins::Asset] asset
58
+ # @return [Array<Array<Symbol,String>>] array of arrays. Each sub array has two elements
59
+ def plan asset
60
+ plans = []
61
+ state_specification(asset).to_option.map { |specification|
62
+ event(specification.name).to_option.map { |ev|
63
+ if not specification_expired?(specification) then
64
+ plans << [:noop, "not yet expired"]
65
+ else
66
+ if ev[:transition] then
67
+ event(ev[:transition]).to_option.map { |ev2|
68
+ plans << [:event, ev2.name]
69
+ }.get_or_else {
70
+ plans << [:exception, "invalid event name #{ev[:transition]}"]
71
+ }
72
+ elsif not ev[:on_transition] then
73
+ plans << [:noop, "no transition specified, and no on_transition action specified"]
74
+ end
75
+ if ev[:on_transition] then
76
+ action(ev[:on_transition]).to_option.map { |ae|
77
+ plans << [:action, ae.name]
78
+ }.get_or_else {
79
+ plans << [:exception, "invalid action specified #{ev[:on_transition]}"]
80
+ }
81
+ end
82
+ end
83
+ }.get_or_else {
84
+ plans << [:exception, "invalid event name #{e.name}"]
85
+ }
86
+ }.get_or_else {
87
+ Collins::Option(initial).map { |init|
88
+ event(init).to_option.map { |ev|
89
+ if ev[:before_transition] then
90
+ action(ev[:before_transition]).to_option.map do |act|
91
+ plans << [:action, act.name]
92
+ end.get_or_else {
93
+ plans << [:exception, "action #{ev[:before_transition]} not defined"]
94
+ }
95
+ end
96
+ plans << [:event, init]
97
+ }.get_or_else {
98
+ plans << [:exception, "initial state #{init} is undefined"]
99
+ }
100
+ }.get_or_else {
101
+ plans << [:exception, "no initial state defined"]
102
+ }
103
+ }
104
+ plans
105
+ end
106
+
107
+ # Reset (delete) the attribute once the process is complete
108
+ #
109
+ # @param [Collins::Asset] asset The asset on which to delete the attribute
110
+ # @return [Boolean] True if the value was successfully deleted
111
+ def reset! asset
112
+ collins_client.delete_attribute! asset, attribute_name
113
+ end
114
+
115
+ # Return the name of the current sate. Will be :None if not initialized
116
+ # @param [Collins::Asset] asset
117
+ # @return [Symbol] state name
118
+ def state_name asset
119
+ state_specification(asset).name
120
+ end
121
+
122
+ # Get the state specification associated with the asset
123
+ #
124
+ # @param [Collins::Asset] asset The asset to retrieve
125
+ # @return [Collins::State::Specification] The spec (be sure to check `defined?`)
126
+ def state_specification asset
127
+ updated = asset_from_cache asset
128
+ result = updated.send(attribute_name.to_sym)
129
+ if result then
130
+ res = JSON.parse(result) rescue nil
131
+ if res.nil? then # for backwards compatibility
132
+ res = JSON.parse(result, :create_additions => false)
133
+ res = ::Collins::State::Specification.json_create(res) if (res.is_a?(Hash) and res.key?('data'))
134
+ end
135
+ if res.is_a?(::Collins::State::Specification) then
136
+ res
137
+ else
138
+ logger.warn("Could not deserialize #{result} to a State Specification")
139
+ ::Collins::State::Specification.empty
140
+ end
141
+ else
142
+ ::Collins::State::Specification.empty
143
+ end
144
+ end
145
+
146
+ # Transition the asset to the next appropriate state
147
+ #
148
+ # This method will either initialize the state on the specified asset (if needed), or process the
149
+ # current state. If processing the current state, the expiration time will be checked, followed by
150
+ # running any specified transition event, followed by any `on_transition` action. The transition
151
+ # event is run before the `on_transition` action, because the `on_transition` action should only be
152
+ # called if we have successfully transitioned to a new state. In the event that the transition
153
+ # event has a `before_transition` defined that fails, we don't want to execute the `on_transition`
154
+ # code since we have not yet successfully transitioned.
155
+ #
156
+ # @param [Collins::Asset] asset The asset to transition
157
+ # @param [Hash] options Transition options
158
+ # @option options [Boolean] :quiet Don't throw an exception if a transition fails
159
+ # @raise [CollinsError] if state needs to be initialized and no `:initial` key is found in the manage_state options hash
160
+ # @raise [CollinsError] if a specification is found on the asset, but the named state isn't found as a registered event
161
+ # @raise [CollinsError] if an action is specified as a `:before_transition` or `:on_transition` value but not registered
162
+ # @return [Collins::State::Specification] The current state, after the transition is run
163
+ def transition asset, options = {}
164
+ state_specification(asset).to_option.map { |specification|
165
+ event(specification.name).to_option.or_else {
166
+ raise CollinsError.new("no event defined with name #{specification.name}")
167
+ }.filter { |e| specification_expired?(specification) }.map { |e|
168
+ if e[:transition] then
169
+ spec = run_event(asset, e[:transition], options)
170
+ run_action(asset, e[:on_transition]) if e[:on_transition]
171
+ # If we transitioned and no expiration is set, rerun
172
+ if specification_expired?(spec) and spec.name != e.name then
173
+ transition(asset, options)
174
+ else
175
+ spec
176
+ end
177
+ else
178
+ logger.debug("No transition event specified for #{e.name}")
179
+ run_action(asset, e[:on_transition], :log => true) if e[:on_transition]
180
+ specification
181
+ end
182
+ }.get_or_else {
183
+ logger.trace("Specification #{specification.name} not yet expired")
184
+ specification
185
+ }
186
+ }.get_or_else {
187
+ init = Collins::Option(initial).get_or_else {
188
+ raise Collins::CollinsError.new("no initial state defined for transition")
189
+ }
190
+ options = Collins::Option(self.class.managed_state_options).get_or_else({})
191
+ run_event(asset, init, options)
192
+ }
193
+ end
194
+
195
+ # Allow registered events to be executed. Allow predicate calls to respond true if the asset is
196
+ # currently in the specified state or false otherwise
197
+ def method_missing method, *args, &block
198
+ if args.length == 0 then
199
+ return super
200
+ end
201
+ asset = args[0]
202
+ options = args[1]
203
+ if not (asset.is_a?(Collins::Asset) || asset.is_a?(String)) then
204
+ return super
205
+ end
206
+ if not options.nil? and not options.is_a?(Hash) then
207
+ return super
208
+ elsif options.nil? then
209
+ options = {}
210
+ end
211
+ question_only = method.to_s.end_with?('?')
212
+ if question_only then
213
+ meth = method.to_s[0..-2].to_sym # drop ? at end
214
+ if event?(meth) then
215
+ state_name(asset) == meth
216
+ else
217
+ false
218
+ end
219
+ elsif event?(method) then
220
+ run_event(asset, method, options)
221
+ else
222
+ super
223
+ end
224
+ end # method_missing
225
+
226
+ def respond_to? method
227
+ question_only = method.to_s.end_with?('?')
228
+ if question_only then
229
+ method = method.to_s[0..-2] # drop? at end
230
+ end
231
+ if not event?(method) then
232
+ super
233
+ else
234
+ true
235
+ end
236
+ end # respond_to?
237
+
238
+ protected
239
+ # Get the callback associated with the specified action name
240
+ # @param [Symbol] name Action name
241
+ # @return [Collins::SimpleCallback] always returns, check `defined?`
242
+ def action name
243
+ name_sym = name.to_sym
244
+ self.class.actions.fetch(name_sym, ::Collins::SimpleCallback.empty)
245
+ end
246
+
247
+ # True if an event with the given name is registered, false otherwise
248
+ # @param [Symbol] name Event name
249
+ # @return [Boolean] Registered or not
250
+ def event? name
251
+ event(name).defined?
252
+ end
253
+
254
+ # Get the callback associated with the specified event name
255
+ # @param [Symbol] name Event name
256
+ # @return [Collins::SimpleCallback] always returns, check `defined?`
257
+ def event name
258
+ name_sym = name.to_sym
259
+ self.class.events.fetch(name_sym, ::Collins::SimpleCallback.empty)
260
+ end
261
+
262
+ # Get the expires time associated with the specified event
263
+ #
264
+ # @param [Symbol] name Name of the event
265
+ # @param [Fixnum] default Value if event not found or expires not specified
266
+ # @return [Fixnum] An integer representing the number of seconds before expiration
267
+ def event_expires name, default = 0
268
+ event(name).to_option.map do |e|
269
+ e.options.fetch(:expires, default.to_i)
270
+ end.get_or_else(default.to_i)
271
+ end
272
+
273
+ # Initial event or nil
274
+ def initial
275
+ Collins::Option(self.class.managed_state_options).
276
+ filter{|h| h.key?(:initial)}.
277
+ map{|h| h[:initial]}.
278
+ get_or_else(nil)
279
+ end
280
+
281
+ # Run the specified action
282
+ #
283
+ # @param [Collins::Asset] asset the asset to run the action for
284
+ # @param [Symbol] name the action name
285
+ # @param [Hash] options
286
+ # @option options [Boolean] :log (true) log action run
287
+ # @return [Boolean] the result of executing the action
288
+ def run_action asset, name, options = {}
289
+ action(name).to_option.map do |a|
290
+ result = case a.arity
291
+ when 2
292
+ a.call(asset, self)
293
+ else
294
+ a.call(asset)
295
+ end
296
+ log_run_action(asset, name, result) if options.fetch(:log, false)
297
+ result
298
+ end.get_or_else {
299
+ raise CollinsError.new("Action #{name} not defined")
300
+ }
301
+ end
302
+
303
+ # Update the asset spec that the action was run
304
+ # @param [Collins::Asset] asset
305
+ # @param [Symbol] action_name
306
+ # @param [Boolean] result of the action that was run
307
+ # @return [Collins::State::Specification]
308
+ def log_run_action asset, action_name, result
309
+ current_spec = state_specification asset
310
+ count = current_spec.fetch(:log, []).size
311
+ log = Hash[:count => count, :timestamp => Time.now.utc.to_i, :name => action_name, :result => result]
312
+ current_spec.<<(:log, log)
313
+ asset_cache_delete asset
314
+ update_asset asset, attribute_name, current_spec
315
+ current_spec
316
+ end
317
+
318
+ # Run the specified event, and execute :before_transition if specified
319
+ #
320
+ # @param [Collins::Asset] asset the asset to process the event for
321
+ # @param [Symbol] name the name of the event to process
322
+ # @param [Hash] options Option for executing the event
323
+ # @option options [Boolean] :quiet Do not throw an exception if an error occurs running actions
324
+ # @return [Collins::State::Specification] the new state the asset is in
325
+ def run_event asset, name, options = {}
326
+ event(name).to_option.map do |e|
327
+ update_state(asset, e, options)
328
+ end.get_or_else {
329
+ raise CollinsError.new("Event #{name} not defined")
330
+ }
331
+ end
332
+
333
+ # Whether the given specification has expired or not
334
+ #
335
+ # This method is primarily useful for testing whether a stored specification has expired yet or
336
+ # not. If a specification has just been created (and has an expiration set), this will obviously
337
+ # return false.
338
+ #
339
+ # @param [Collins::State::Specification] specification
340
+ # @return [Boolean] true if expired, false otherwise
341
+ def specification_expired? specification
342
+ timestamp = specification.timestamp
343
+ name = specification.name
344
+ expires_at = timestamp + event_expires(name)
345
+ # Must be >= otherwise 0 + Time.now as expires_at will fail
346
+ Time.now.utc.to_i >= expires_at
347
+ end
348
+
349
+ # Update asset state using the specified event information
350
+ #
351
+ # This method also handles executing appropriate transition related actions and managing failures.
352
+ # The actual code for updating the asset itself is in #update_asset
353
+ #
354
+ # @param [Collins::Asset] asset the asset to update
355
+ # @param [Collins::SimpleCallback] event the event
356
+ # @param [Hash] options Option for executing the event
357
+ # @option options [Boolean] :quiet Do not throw an exception if an error occurs running actions
358
+ def update_state asset, event, options = {}
359
+ if event[:before_transition] then
360
+ run_before_transition asset, event[:before_transition], event, options
361
+ else
362
+ specification = Collins::State::Specification.new event.name, event[:desc], Time.now
363
+ specification = specification.merge(state_specification(asset))
364
+ asset_cache_delete asset
365
+ res = update_asset asset, attribute_name, specification
366
+ # Horrible hack to allow update_asset to be overridden such that it can provide a string
367
+ # for performing an update, in which case we need the actual command
368
+ if res.is_a?(String) then
369
+ return res
370
+ else
371
+ return specification
372
+ end
373
+ end
374
+ end
375
+
376
+ # Run the specified before_transition
377
+ # We try running the specified action, and if successful update the asset with the specification.
378
+ # If running the action fails, we update the attempts count on the current specification and
379
+ # either throw an exception (default behavior) or return the updated spec (options[:quiet] ==
380
+ # true). On success we return the new specification.
381
+ def run_before_transition asset, action_name, event, options
382
+ before_result =
383
+ begin
384
+ run_action(asset, action_name)
385
+ rescue Exception => e
386
+ logger.warn("Failed running #{action_name}: #{e}")
387
+ false
388
+ end
389
+ if before_result != false
390
+ specification = Collins::State::Specification.new event.name, event[:desc], Time.now
391
+ specification = specification.merge(state_specification(asset))
392
+ asset_cache_delete asset
393
+ update_asset asset, attribute_name, specification
394
+ specification
395
+ else
396
+ current_spec = state_specification(asset)
397
+ count = current_spec.fetch(:attempts, []).size
398
+ attempts = Hash[:timestamp => Time.now.utc.to_i, :count => count, :name => event.name]
399
+ current_spec.<<(:attempts, attempts)
400
+ asset_cache_delete asset
401
+ update_asset asset, attribute_name, current_spec
402
+ if options.fetch(:quiet, false) then
403
+ return current_spec
404
+ else
405
+ raise CollinsError.new("Can't transition from #{current_spec.name} to #{event.name}, before_transition failed")
406
+ end
407
+ end
408
+ end
409
+
410
+ # Update the asset using the specified key and JSON
411
+ #
412
+ # This method is broken out this way so it can be easily overriden
413
+ # @param [Collins::Asset] asset The asset to update
414
+ # @param [String] key The attribute to update on the asset
415
+ # @param [Specification] spec The state specification to set as the value associated with the key
416
+ # @return [Boolean] indication of success or failure
417
+ def update_asset asset, key, spec
418
+ collins_client.set_attribute! asset, key, spec.to_json
419
+ end
420
+
421
+ private
422
+ def asset_cache_delete asset
423
+ tag = Collins::Util.get_asset_or_tag(asset).tag
424
+ @_asset_cache.delete(tag) if (@_asset_cache && @_asset_cache.key?(tag))
425
+ end
426
+ def asset_from_cache asset
427
+ tag = Collins::Util.get_asset_or_tag(asset).tag
428
+ @_asset_cache = {} unless @_asset_cache
429
+ if not @_asset_cache.key?(tag) then
430
+ @_asset_cache[tag] = collins_client.get(tag)
431
+ end
432
+ @_asset_cache[tag]
433
+ end
434
+
435
+ end; end; end
@@ -0,0 +1,128 @@
1
+ require 'collins/simple_callback'
2
+
3
+ module Collins; module State; module Mixin
4
+
5
+ # Static (Class) methods to be added to classes that mixin this module. These methods provide
6
+ # functionality for registering new actions and events, along with access to the managed state.
7
+ # This is accomplished via class instance variables which allow each class that include the
8
+ # {Collins::State::Mixin} its own managed state and variables.
9
+ module ClassMethods
10
+
11
+ # @return [Hash<Symbol, Collins::SimpleCallback>] Registered actions for the managed state
12
+ attr_reader :actions
13
+
14
+ # @return [Hash<Symbol, Collins::SimpleCallback>] Registered events for the managed state
15
+ attr_reader :events
16
+
17
+ # Register a managed state for this class
18
+ #
19
+ # @note Only one managed state per class is allowed.
20
+ # @param [Symbol] name The name of the managed state, e.g. `:some_process`
21
+ # @param [Hash] options Managed state options
22
+ # @option options [Symbol] :initial First event to fire if the state is being initialized
23
+ # @yieldparam [Collins::Client] block Managed state description, registering events and actions
24
+ #
25
+ # @example
26
+ # manage_state :a_process, :initial => :start do |client|
27
+ # action :log_it do |asset|
28
+ # client.log! asset, "Did some logging"
29
+ # end
30
+ # event :start, :desc => 'Initial state', :expires => after(30, :minutes), :on_transition => :log_it, :transition => :done
31
+ # event :done, :desc => 'Done'
32
+ # end
33
+ def manage_state name, options = {}, &block
34
+ @actions = {}
35
+ @events = {}
36
+ @managed_state = ::Collins::SimpleCallback.new(name, options, block)
37
+ end
38
+
39
+ # @return [String] Name of managed state associated with this class
40
+ def managed_state_name
41
+ @managed_state.name
42
+ end
43
+
44
+ # @return [Hash] Options associated with this managed state
45
+ def managed_state_options
46
+ @managed_state.options
47
+ end
48
+
49
+ # Get and execute the managed state associated with this class
50
+ #
51
+ # @see {#manage_state}
52
+ # @param [Collins::Client] client Collins client instance
53
+ def managed_state client
54
+ @managed_state.call(client)
55
+ end
56
+
57
+ # Register a named action for use as a transition execution target
58
+ #
59
+ # Actions are called when specified by a `:before_transition` or `:on_transition` option to an
60
+ # event. Options are not currently used.
61
+ #
62
+ # @param [Symbol] name Action name
63
+ # @param [Hash] options Action options
64
+ # @yieldparam [Collins::Asset] block Given an asset, perform the specified action
65
+ # @yieldreturn [Boolean,Object] indicates success or failure of operation. Only `false` (or an exception) indicate failure. Other return values are fine.
66
+ # @return [Collins::SimpleCallback] the callback created for the action
67
+ def action name, options = {}, &block
68
+ name_sym = name.to_sym
69
+ new_action = ::Collins::SimpleCallback.new(name_sym, options, block)
70
+ @actions[name_sym] = new_action
71
+ new_action
72
+ end
73
+
74
+ # Register a named event associated with the managed state
75
+ #
76
+ # Events are typically called as method invocations on the class, or as the result of a state
77
+ # being expired and a transition being specified.
78
+ #
79
+ # The example (and events in general) can be read as: Execute `:before_transition` before
80
+ # successfully transitioning to this event. Once transitioned, after `:expires` is reached the
81
+ # `:on_transition` action should be called, followed by the event associated with the specified
82
+ # `:transition`.
83
+ #
84
+ # @param [Symbol] name Event name
85
+ # @param [Hash] options Event options
86
+ # @option options [Symbol] :before_transition An action to execute successfully before transitioning to this state
87
+ # @option options [String] :desc A description of the event, required
88
+ # @option options [#to_i] :expires Do not consider `:on_transition` or `:transition` until after this amount of time
89
+ # @option options [Symbol] :on_transition Once the expiration time has passed execute this action
90
+ # @option options [Symbol] :transition The event to call after its appropriate for transition (due to timeout and successful `:before` calls)
91
+ # @return [Collins::SimpleCallback] the callback created for the action
92
+ #
93
+ # @example
94
+ # event :stuff, :desc => 'I do things', :before_transition => :try_action, :expires => after(5, :minutes),
95
+ # :transition => :after_stuff_event, :on_transition => :stuff_action
96
+ #
97
+ # @note A transition will not occur unless the :before_transition is successful (does not return false or throw an exception)
98
+ # @raise [KeyError] if the options hash is missing a `:desc` key
99
+ def event name, options = {}
100
+ name_sym = name.to_sym
101
+ ::Collins::Option(options[:desc]).or_else {
102
+ raise KeyError.new("Event #{name} is missing :desc key")
103
+ }
104
+ new_event = ::Collins::SimpleCallback.new(name_sym, options)
105
+ @events[name_sym] = new_event
106
+ new_event
107
+ end
108
+
109
+ # Convert a `Fixnum` into seconds based on the specified `time_unit`
110
+ #
111
+ # @param [Fixnum] duration Time value
112
+ # @param [Symbol] time_unit Unit of time, one of `:hour`, `:hours`, `:minute`, `:minutes`.
113
+ # @return [Fixnum] Value in seconds
114
+ def after duration, time_unit = :seconds
115
+ multiplier = case time_unit
116
+ when :hours, :hour
117
+ 60*60
118
+ when :minutes, :minute
119
+ 60
120
+ else
121
+ 1
122
+ end
123
+ multiplier * duration.to_i
124
+ end
125
+
126
+ end # Collins::State::Mixin::ClassMethods
127
+
128
+ end; end; end
@@ -0,0 +1,192 @@
1
+ require 'json'
2
+
3
+ module Collins; module State
4
+
5
+ # Represents a managed state
6
+ #
7
+ # Modeling a state machine like process in collins is useful for multi-step processes such as
8
+ # decommissioning hardware (where you want the process to span several days, with several
9
+ # discrete steps), or monitoring some process and taking action. A
10
+ # {Specification} provides a common format for storing state information
11
+ # as a value of an asset.
12
+ #
13
+ # This will rarely be used directly, but rather is a byproduct of using
14
+ # {Collins::State::Mixin}
15
+ class Specification
16
+ include ::Collins::Util
17
+
18
+ # Used as name placeholder when unspecified
19
+ EMPTY_NAME = :None
20
+ # Used as description placeholder when unspecified
21
+ EMPTY_DESCRIPTION = "Unspecified"
22
+
23
+ # Create an empty specification
24
+ # @return [Collins::State::Specification] spec
25
+ def self.empty
26
+ ::Collins::State::Specification.new :none => true
27
+ end
28
+
29
+ # Create an instance from JSON data
30
+ #
31
+ # @note This method is required by the JSON module for deserialization
32
+ # @param [Hash] json JSON data
33
+ # @return [Collins::State::Specification] spec
34
+ def self.json_create json
35
+ ::Collins::State::Specification.new json['data']
36
+ end
37
+
38
+ # @return [Symbol] Name of the specification.
39
+ # @see Collins::State::Mixin::ClassMethods#event
40
+ # @note This is a unique key and should not change.
41
+ attr_reader :name
42
+
43
+ # @return [String] State description, for humans
44
+ # @see Collins::State::Mixin::ClassMethods#event
45
+ attr_reader :description
46
+
47
+ # @return [Fixnum] Unixtime, UTC, when this state was entered
48
+ attr_accessor :timestamp
49
+
50
+ # @return [Hash] Additional meta-data
51
+ attr_reader :extras
52
+
53
+ # Instantiate a new Specification
54
+ #
55
+ # @param [Hash,(Symbol,String,Fixnum)] args Arguments for instantiation
56
+ # @option args [String,Symbol] :name The name of the specification
57
+ # @option args [String] :description A description of the specification
58
+ # @option args [Fixnum,Time,String] :timestamp (Time.at(0).utcto_i) The time the event occurred
59
+ #
60
+ # @example
61
+ # Specification.new :start, 'I am a state', Time.now
62
+ # Specification.new :start, :description => 'Hello World', :timestamp => 0
63
+ #
64
+ # @note If the specified timestamp is not a `Fixnum` (unixtime), the value is converted to a fixnum
65
+ # @raise [ArgumentError] when `timestamp` is not a `Time`, `String` or `Fixnum`
66
+ # @raise [ArgumentError] when `name` or `description` not specified
67
+ def initialize *args
68
+ opts = {}
69
+ while arg = args.shift do
70
+ if arg.is_a?(Hash) then
71
+ opts.update(arg)
72
+ else
73
+ key = [:name, :description, :timestamp].select{|k| !opts.key?(k)}.first
74
+ opts.update(key => arg) unless key.nil?
75
+ end
76
+ end
77
+ opts = symbolize_hash(opts)
78
+
79
+ if opts.fetch(:none, false) then
80
+ @name = EMPTY_NAME
81
+ @description = EMPTY_DESCRIPTION
82
+ else
83
+ @name = ::Collins::Option(opts.delete(:name)).map{|s| s.to_sym}.get_or_else {
84
+ raise ArgumentError.new("Name not specified")
85
+ }
86
+ @description = ::Collins::Option(opts.delete(:description)).get_or_else {
87
+ raise ArgumentError.new("Description not specified")
88
+ }
89
+ end
90
+ ts = ::Collins::Option(opts.delete(:timestamp)).get_or_else(Time.at(0))
91
+ @timestamp = parse_timestamp(ts)
92
+ # Flatten if needed
93
+ if opts.key?(:extras) then
94
+ @extras = opts[:extras]
95
+ else
96
+ @extras = opts
97
+ end
98
+ end
99
+
100
+ # merges appropriate extras from the other spec into this one
101
+ # @param [Collins::State::Specification] other
102
+ def merge other
103
+ ext = other.extras.merge(@extras)
104
+ ext.delete(:none)
105
+ @extras = ext
106
+ self
107
+ end
108
+
109
+ # @return [Boolean] Indicate whether Specification is empty or not
110
+ def empty?
111
+ !self.defined?
112
+ end
113
+
114
+ # @return [Boolean] Indicate whether Specification is defined or not
115
+ def defined?
116
+ @name != EMPTY_NAME || @description != EMPTY_DESCRIPTION
117
+ end
118
+
119
+ # @return [Collins::Option] None if undefined/empty
120
+ def to_option
121
+ if self.defined? then
122
+ ::Collins::Some(self)
123
+ else
124
+ ::Collins::None()
125
+ end
126
+ end
127
+
128
+ def [](key)
129
+ @extras[key.to_sym]
130
+ end
131
+ def key?(key)
132
+ @extras.key?(key.to_sym)
133
+ end
134
+ def fetch(key, default)
135
+ @extras.fetch(key.to_sym, default)
136
+ end
137
+ def []=(key, value)
138
+ @extras[key.to_sym] = value
139
+ end
140
+
141
+ def <<(key, value)
142
+ @extras[key.to_sym] = [] unless @extras.key?(key.to_sym)
143
+ @extras[key.to_sym] << value
144
+ @extras[key.to_sym]
145
+ end
146
+
147
+ # Convert this instance to JSON
148
+ #
149
+ # @note this is required by the JSON module
150
+ # @return [String] JSON string representation of object
151
+ def to_json(*a)
152
+ {
153
+ 'json_class' => self.class.name,
154
+ 'data' => to_hash
155
+ }.to_json(*a)
156
+ end
157
+
158
+ # @return [Hash] Hash representation of data
159
+ def to_hash
160
+ h = Hash[:name => name, :description => description, :timestamp => timestamp]
161
+ h[:extras] = extras unless extras.empty?
162
+ h
163
+ end
164
+
165
+ # @return [String] human readable
166
+ def to_s
167
+ "Specification(name = #{name}, description = #{description}, timestamp = #{timestamp}, extras = #{extras})"
168
+ end
169
+
170
+ # Mostly used for testing
171
+ def ==(other)
172
+ (other.class == self.class) &&
173
+ other.name == self.name &&
174
+ other.timestamp == self.timestamp
175
+ end
176
+
177
+ private
178
+ def parse_timestamp ts
179
+ if ts.is_a?(String) then
180
+ ts.to_s.to_i
181
+ elsif ts.is_a?(Time) then
182
+ ts.utc.to_i
183
+ elsif ts.is_a?(Fixnum) then
184
+ ts
185
+ else
186
+ raise ArgumentError.new("timestamp is not a String, Time, or Fixnum")
187
+ end
188
+ end
189
+
190
+ end # class Specification
191
+
192
+ end; end
@@ -0,0 +1,57 @@
1
+ require 'collins_state'
2
+
3
+ module Collins
4
+ class ProvisioningWorkflow < ::Collins::PersistentState
5
+
6
+ manage_state :provisioning_process, :initial => :start do |client|
7
+
8
+ action :reboot_hard do |asset, p|
9
+ p.logger.warn "Calling rebootHard on asset #{Collins::Util.get_asset_or_tag(asset).tag}"
10
+ client.power! asset, "rebootHard"
11
+ end
12
+
13
+ action :reprovision do |asset, p|
14
+ detailed = client.get asset
15
+ p.logger.warn "Reprovisioning #{detailed.tag}"
16
+ client.set_status! asset, "Maintenance"
17
+ client.provision asset, detailed.nodeclass, detailed.contact, :suffix => detailed.suffix,
18
+ :primary_role => detailed.primary_role, :secondary_role => detailed.secondary_role,
19
+ :pool => detailed.pool
20
+ end
21
+
22
+ action :toggle_status do |asset, p|
23
+ tag = Collins::Util.get_asset_or_tag(asset).tag
24
+ p.logger.warn "Toggling status (Provisioning then Provisioned) for #{tag}"
25
+ client.set_status!(asset, 'Provisioning') && client.set_status!(asset, 'Provisioned')
26
+ end
27
+
28
+ # No transitions are defined here, since they are managed fully asynchronously by the
29
+ # supervisor process. We only defines actions to take once the timeout has passed. Each
30
+ # part of the process makes an event call, the supervisor process just continuously does a
31
+ # collins.managed_process("ProvisioningWorkflow").transition(asset) which will
32
+ # either execute the on_transition action due to expiration or do nothing.
33
+
34
+ # After 30 minutes reprovision if still in the start state - ewr_start_provisioning - collins.managed_process("ProvisioningWorkflow").start(asset)
35
+ event :start, :desc => 'Provisioning Started', :expires => after(30, :minutes), :on_transition => :reprovision
36
+ # After 10 minutes if we haven't seen an ipxe request, reprovision (possible failed move) - vlan changer & provisioning status - collins.mp.vlan_moved_to_provisioning(asset)
37
+ event :vlan_moved_to_provisioning, :desc => 'Moved to provisioning VLAN', :expires => after(15, :minutes), :on_transition => :reprovision
38
+ # After 5 minutes if we haven't yet seen a kickstart request, reboot (possibly stuck at boot) - phil ipxe
39
+ event :ipxe_seen, :desc => 'Asset has made iPXE request to Phil', :expires => after(5, :minutes), :on_transition => :reboot_hard
40
+ # After 5 minutes if the kickstart process hasn't begun, reboot (possibly stuck at boot) - phil kickstart
41
+ event :kickstart_seen, :desc => 'Asset has made kickstart request to Phil', :expires => after(15, :minutes), :on_transition => :reboot_hard
42
+ # After 45 minutes if we haven't gotten to post, reboot to reinstall - phil kickstart pre
43
+ event :kickstart_started, :desc => 'Asset has started kickstart process', :expires => after(45, :minutes), :on_transition => :reboot_hard
44
+ # After 45 minutes if the kickstart process hasn't completed, reboot to reinstall - phil kickstart post
45
+ event :kickstart_post_started, :desc => 'Asset has started kickstart post section', :expires => after(45, :minutes), :on_transition => :reboot_hard
46
+ # After 10 minutes if we haven't been moved to the production VLAN, toggle our status to get us moved - phil kickstart post
47
+ event :kickstart_finished, :desc => 'Asset has finished kickstart process', :expires => after(15, :minutes), :on_transition => :toggle_status
48
+ # After another 15 minutes if still not moved toggle our status again - vlan changer when discover=true
49
+ event :vlan_move_to_production, :desc => 'Moved to production VLAN', :expires => after(15, :minutes), :on_transition => :toggle_status
50
+ # After another 10 minutes if we're not reachable by IP, toggle the status - supervisor
51
+ event :reachable_by_ip, :desc => 'Now reachable by IP address', :expires => after(10, :minutes), :on_transition => :toggle_status
52
+ # Place holder for when we finish
53
+ event :done, :desc => 'Finished', :terminus => true
54
+ end
55
+ end
56
+
57
+ end
@@ -0,0 +1,4 @@
1
+ $:.unshift File.join File.dirname(__FILE__)
2
+ require 'collins_client'
3
+ require 'collins/persistent_state'
4
+ Dir[File.join(File.dirname(__FILE__), 'collins', 'workflows', '*.rb')].each {|f| require f}
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: collins_state
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.10
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Blake Matheny
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-10-31 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: collins_client
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 0.2.7
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 0.2.7
30
+ - !ruby/object:Gem::Dependency
31
+ name: escape
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 0.0.4
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 0.0.4
46
+ description: Provides basic framework for managing stateful processes with collins
47
+ email: bmatheny@tumblr.com
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files:
51
+ - README.md
52
+ files:
53
+ - Gemfile
54
+ - Gemfile.lock
55
+ - README.md
56
+ - Rakefile
57
+ - VERSION
58
+ - collins_state.gemspec
59
+ - lib/collins/persistent_state.rb
60
+ - lib/collins/state/mixin.rb
61
+ - lib/collins/state/mixin_class_methods.rb
62
+ - lib/collins/state/specification.rb
63
+ - lib/collins/workflows/provisioning_workflow.rb
64
+ - lib/collins_state.rb
65
+ homepage: https://github.com/tumblr/collins/tree/master/support/ruby/collins-state
66
+ licenses:
67
+ - APL 2.0
68
+ post_install_message:
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ segments:
79
+ - 0
80
+ hash: -3397090300883848228
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ! '>='
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubyforge_project:
89
+ rubygems_version: 1.8.24
90
+ signing_key:
91
+ specification_version: 3
92
+ summary: Collins based state management
93
+ test_files: []