smart_proxy_dynflow 0.2.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +6 -25
  3. data/{bundler.plugins.d → bundler.d}/dynflow.rb +0 -0
  4. data/lib/smart_proxy_dynflow.rb +16 -1
  5. data/lib/smart_proxy_dynflow/action.rb +12 -0
  6. data/lib/smart_proxy_dynflow/action/batch.rb +21 -0
  7. data/lib/smart_proxy_dynflow/action/batch_callback.rb +20 -0
  8. data/lib/smart_proxy_dynflow/action/batch_runner.rb +14 -0
  9. data/lib/smart_proxy_dynflow/action/output_collector.rb +8 -0
  10. data/lib/smart_proxy_dynflow/action/runner.rb +76 -0
  11. data/lib/smart_proxy_dynflow/action/shareable.rb +25 -0
  12. data/lib/smart_proxy_dynflow/action/single_runner_batch.rb +39 -0
  13. data/lib/smart_proxy_dynflow/api.rb +63 -40
  14. data/lib/smart_proxy_dynflow/callback.rb +69 -25
  15. data/lib/smart_proxy_dynflow/continuous_output.rb +50 -0
  16. data/lib/smart_proxy_dynflow/core.rb +121 -0
  17. data/lib/smart_proxy_dynflow/helpers.rb +52 -6
  18. data/lib/smart_proxy_dynflow/http_config.ru +6 -16
  19. data/lib/smart_proxy_dynflow/log.rb +52 -0
  20. data/lib/smart_proxy_dynflow/middleware/keep_current_request_id.rb +59 -0
  21. data/lib/smart_proxy_dynflow/otp_manager.rb +36 -0
  22. data/lib/smart_proxy_dynflow/plugin.rb +9 -14
  23. data/lib/smart_proxy_dynflow/proxy_adapter.rb +1 -1
  24. data/lib/smart_proxy_dynflow/runner.rb +10 -0
  25. data/lib/smart_proxy_dynflow/runner/base.rb +98 -0
  26. data/lib/smart_proxy_dynflow/runner/command.rb +40 -0
  27. data/lib/smart_proxy_dynflow/runner/command_runner.rb +11 -0
  28. data/lib/smart_proxy_dynflow/runner/dispatcher.rb +191 -0
  29. data/lib/smart_proxy_dynflow/runner/parent.rb +57 -0
  30. data/lib/smart_proxy_dynflow/runner/update.rb +28 -0
  31. data/lib/smart_proxy_dynflow/settings.rb +9 -0
  32. data/lib/smart_proxy_dynflow/settings_loader.rb +53 -0
  33. data/lib/smart_proxy_dynflow/task_launcher.rb +9 -0
  34. data/lib/smart_proxy_dynflow/task_launcher/abstract.rb +44 -0
  35. data/lib/smart_proxy_dynflow/task_launcher/batch.rb +37 -0
  36. data/lib/smart_proxy_dynflow/task_launcher/group.rb +48 -0
  37. data/lib/smart_proxy_dynflow/task_launcher/single.rb +17 -0
  38. data/lib/smart_proxy_dynflow/task_launcher_registry.rb +31 -0
  39. data/lib/smart_proxy_dynflow/testing.rb +24 -0
  40. data/lib/smart_proxy_dynflow/ticker.rb +47 -0
  41. data/lib/smart_proxy_dynflow/version.rb +2 -2
  42. data/settings.d/dynflow.yml.example +6 -5
  43. metadata +83 -12
@@ -0,0 +1,50 @@
1
+ module Proxy::Dynflow
2
+ class ContinuousOutput
3
+ attr_accessor :raw_outputs
4
+
5
+ def initialize(raw_outputs = [])
6
+ @raw_outputs = []
7
+ raw_outputs.each { |raw_output| add_raw_output(raw_output) }
8
+ end
9
+
10
+ def add_raw_output(raw_output)
11
+ missing_args = %w[output_type output timestamp] - raw_output.keys
12
+ unless missing_args.empty?
13
+ raise ArgumentError, "Missing args for raw output: #{missing_args.inspect}"
14
+ end
15
+ @raw_outputs << raw_output
16
+ end
17
+
18
+ def empty?
19
+ @raw_outputs.empty?
20
+ end
21
+
22
+ def last_timestamp
23
+ return if @raw_outputs.empty?
24
+ @raw_outputs.last.fetch('timestamp')
25
+ end
26
+
27
+ def sort!
28
+ @raw_outputs.sort_by! { |record| record['timestamp'].to_f }
29
+ end
30
+
31
+ def humanize
32
+ sort!
33
+ raw_outputs.map { |output| output['output'] }.join("\n")
34
+ end
35
+
36
+ def add_exception(context, exception, timestamp = Time.now.getlocal)
37
+ add_output(context + ": #{exception.class} - #{exception.message}", 'debug', timestamp)
38
+ end
39
+
40
+ def add_output(*args)
41
+ add_raw_output(self.class.format_output(*args))
42
+ end
43
+
44
+ def self.format_output(message, type = 'debug', timestamp = Time.now.getlocal)
45
+ { 'output_type' => type,
46
+ 'output' => message,
47
+ 'timestamp' => timestamp.to_f }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,121 @@
1
+ module Proxy::Dynflow
2
+ class Core
3
+ attr_accessor :world, :accepted_cert_serial
4
+
5
+ def initialize
6
+ @world = create_world
7
+ cert_file = Proxy::SETTINGS.foreman_ssl_cert || Proxy::SETTINGS.ssl_certificate
8
+ if cert_file
9
+ client_cert = File.read(cert_file)
10
+ # we trust only requests using the same certificate as we are
11
+ # (in other words the local proxy only)
12
+ @accepted_cert_serial = OpenSSL::X509::Certificate.new(client_cert).serial
13
+ end
14
+ end
15
+
16
+ def create_world(&block)
17
+ config = default_world_config(&block)
18
+ world = ::Dynflow::World.new(config)
19
+ world.middleware.use ::Actions::Middleware::KeepCurrentRequestID
20
+ world
21
+ end
22
+
23
+ def persistence_conn_string
24
+ return ENV['DYNFLOW_DB_CONN_STRING'] if ENV.key? 'DYNFLOW_DB_CONN_STRING'
25
+ db_conn_string = 'sqlite:/'
26
+
27
+ db_file = Settings.instance.database
28
+ if db_file.nil? || db_file.empty?
29
+ Log.instance.warn "Could not open DB for dynflow at '#{db_file}', " \
30
+ "will keep data in memory. Restart will drop all dynflow data."
31
+ else
32
+ db_conn_string += "/#{db_file}"
33
+ end
34
+
35
+ db_conn_string
36
+ end
37
+
38
+ def persistence_adapter
39
+ ::Dynflow::PersistenceAdapters::Sequel.new persistence_conn_string
40
+ end
41
+
42
+ def default_world_config
43
+ ::Dynflow::Config.new.tap do |config|
44
+ config.auto_rescue = true
45
+ config.logger_adapter = logger_adapter
46
+ config.persistence_adapter = persistence_adapter
47
+ config.execution_plan_cleaner = execution_plan_cleaner
48
+ # TODO: There has to be a better way
49
+ matchers = config.silent_dead_letter_matchers.call.concat(self.class.silencer_matchers)
50
+ config.silent_dead_letter_matchers = matchers
51
+ yield config if block_given?
52
+ end
53
+ end
54
+
55
+ def logger_adapter
56
+ Log::ProxyAdapter.new(Proxy::LogBuffer::Decorator.instance, Log.instance.level)
57
+ end
58
+
59
+ def execution_plan_cleaner
60
+ proc do |world|
61
+ age = Settings.instance.execution_plan_cleaner_age
62
+ options = { :poll_interval => age, :max_age => age }
63
+ ::Dynflow::Actors::ExecutionPlanCleaner.new(world, options)
64
+ end
65
+ end
66
+
67
+ class << self
68
+ attr_reader :instance
69
+
70
+ def ensure_initialized
71
+ return @instance if @instance
72
+ @instance = Core.new
73
+ after_initialize_blocks.each { |block| block.call(@instance) }
74
+ @instance
75
+ end
76
+
77
+ def silencer_matchers
78
+ @matchers ||= [::Dynflow::DeadLetterSilencer::Matcher.new(Ticker)]
79
+ end
80
+
81
+ def register_silencer_matchers(matchers)
82
+ silencer_matchers.concat matchers
83
+ end
84
+
85
+ def web_console
86
+ require 'dynflow/web'
87
+ dynflow_console = ::Dynflow::Web.setup do
88
+ # we can't use the proxy's after_activation hook, as
89
+ # it happens before the Daemon forks the process (including
90
+ # closing opened file descriptors)
91
+ # TODO: extend smart proxy to enable hooks that happen after
92
+ # the forking
93
+ helpers Helpers
94
+ include ::Sinatra::Authorization::Helpers
95
+
96
+ before do
97
+ do_authorize_with_ssl_client if Settings.instance.console_auth
98
+ end
99
+
100
+ Core.ensure_initialized
101
+ set :world, Core.world
102
+ end
103
+ dynflow_console
104
+ end
105
+
106
+ def world
107
+ instance.world
108
+ end
109
+
110
+ def after_initialize(&block)
111
+ after_initialize_blocks << block
112
+ end
113
+
114
+ private
115
+
116
+ def after_initialize_blocks
117
+ @after_initialize_blocks ||= []
118
+ end
119
+ end
120
+ end
121
+ end
@@ -1,11 +1,57 @@
1
1
  module Proxy
2
- class Dynflow
2
+ module Dynflow
3
3
  module Helpers
4
- def relay_request(from = %r{^/dynflow}, to = '')
5
- response = Proxy::Dynflow::Callback::Core.relay(request, from, to)
6
- content_type response.content_type
7
- status response.code
8
- body response.body
4
+ def world
5
+ Proxy::Dynflow::Core.world
6
+ end
7
+
8
+ def authorize_with_token(task_id:, clear: true)
9
+ if request.env.key? 'HTTP_AUTHORIZATION'
10
+ if defined?(::Proxy::Dynflow)
11
+ auth = request.env['HTTP_AUTHORIZATION']
12
+ basic_prefix = /\ABasic /
13
+ if !auth.to_s.empty? && auth =~ basic_prefix &&
14
+ Proxy::Dynflow::OtpManager.authenticate(auth.gsub(basic_prefix, ''),
15
+ expected_user: task_id, clear: clear)
16
+ Log.instance.debug('authorized with token')
17
+ return true
18
+ end
19
+ end
20
+ halt 403, MultiJson.dump(:error => 'Invalid username or password supplied')
21
+ end
22
+ false
23
+ end
24
+
25
+ def trigger_task(*args)
26
+ triggered = world.trigger(*args)
27
+ { :task_id => triggered.id }
28
+ end
29
+
30
+ def cancel_task(task_id)
31
+ execution_plan = world.persistence.load_execution_plan(task_id)
32
+ cancel_events = execution_plan.cancel
33
+ { :task_id => task_id, :canceled_steps_count => cancel_events.size }
34
+ end
35
+
36
+ def task_status(task_id)
37
+ ep = world.persistence.load_execution_plan(task_id)
38
+ ep.to_hash.merge(:actions => ep.actions.map(&:to_hash))
39
+ rescue KeyError => _e
40
+ status 404
41
+ {}
42
+ end
43
+
44
+ def tasks_count(state)
45
+ state ||= 'all'
46
+ filter = state != 'all' ? { :filters => { :state => [state] } } : {}
47
+ tasks = world.persistence.find_execution_plans(filter)
48
+ { :count => tasks.count, :state => state }
49
+ end
50
+
51
+ def dispatch_external_event(task_id, params)
52
+ world.event(task_id,
53
+ params['step_id'].to_i,
54
+ ::Proxy::Dynflow::Runner::ExternalEvent.new(params))
9
55
  end
10
56
  end
11
57
  end
@@ -1,21 +1,11 @@
1
- # Internal core will be used if external core is either disabled or unset
2
- # and the core gem can be loaded
1
+ require 'smart_proxy_dynflow/api'
3
2
 
4
- if !::Proxy::Dynflow::Plugin.settings.external_core && Proxy::Dynflow::Plugin.internal_core_available?
5
- require 'smart_proxy_dynflow_core/api'
6
- require 'smart_proxy_dynflow_core/launcher'
7
-
8
- SmartProxyDynflowCore::Settings.load_from_proxy(p)
9
-
10
- map "/dynflow" do
11
- SmartProxyDynflowCore::Launcher.route_mapping(self)
3
+ map "/dynflow" do
4
+ map '/console' do
5
+ run Proxy::Dynflow::Core.web_console
12
6
  end
13
- else
14
- require 'smart_proxy_dynflow/api'
15
7
 
16
- map "/dynflow" do
17
- map '/' do
18
- run Proxy::Dynflow::Api
19
- end
8
+ map '/' do
9
+ run Proxy::Dynflow::Api
20
10
  end
21
11
  end
@@ -0,0 +1,52 @@
1
+ require 'logging'
2
+
3
+ module Proxy::Dynflow
4
+ class Log
5
+ LOGGER_NAME = 'dynflow-core'.freeze
6
+
7
+ begin
8
+ require 'syslog/logger'
9
+ @syslog_available = true
10
+ rescue LoadError
11
+ @syslog_available = false
12
+ end
13
+
14
+ class << self
15
+ def reload!
16
+ Logging.logger[LOGGER_NAME].appenders.each(&:close)
17
+ Logging.logger[LOGGER_NAME].clear_appenders
18
+ @logger = nil
19
+ instance
20
+ end
21
+
22
+ def instance
23
+ ::Proxy::LogBuffer::Decorator.instance
24
+ end
25
+ end
26
+
27
+ class ProxyStructuredFormater < ::Dynflow::LoggerAdapters::Formatters::Abstract
28
+ def format(message)
29
+ if message.is_a?(Exception)
30
+ subject = "#{message.message} (#{message.class})"
31
+ if @base.respond_to?(:exception)
32
+ @base.exception("Error details", message)
33
+ subject
34
+ else
35
+ "#{subject}\n#{message.backtrace.join("\n")}"
36
+ end
37
+ else
38
+ @original_formatter.call(severity, datetime, prog_name, message)
39
+ end
40
+ end
41
+ end
42
+
43
+ class ProxyAdapter < ::Dynflow::LoggerAdapters::Simple
44
+ def initialize(logger, level = Logger::DEBUG, _formatters = [])
45
+ @logger = logger
46
+ @logger.level = level
47
+ @action_logger = apply_formatters(ProgNameWrapper.new(@logger, ' action'), [])
48
+ @dynflow_logger = apply_formatters(ProgNameWrapper.new(@logger, 'dynflow'), [])
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,59 @@
1
+ module Actions
2
+ module Middleware
3
+ class KeepCurrentRequestID < Dynflow::Middleware
4
+ def delay(*args)
5
+ pass(*args).tap { store_current_request_id }
6
+ end
7
+
8
+ def plan(*args)
9
+ with_current_request_id do
10
+ pass(*args).tap { store_current_request_id }
11
+ end
12
+ end
13
+
14
+ def run(*args)
15
+ restore_current_request_id { pass(*args) }
16
+ end
17
+
18
+ def finalize
19
+ restore_current_request_id { pass }
20
+ end
21
+
22
+ # Run all execution plan lifecycle hooks as the original request_id
23
+ def hook(*args)
24
+ restore_current_request_id { pass(*args) }
25
+ end
26
+
27
+ private
28
+
29
+ def with_current_request_id
30
+ if action.input[:current_request_id].nil?
31
+ yield
32
+ else
33
+ restore_current_request_id { yield }
34
+ end
35
+ end
36
+
37
+ def store_current_request_id
38
+ action.input[:current_request_id] = ::Logging.mdc['request']
39
+ end
40
+
41
+ def restore_current_request_id
42
+ unless (restored_id = action.input[:current_request_id]).nil?
43
+ old_id = ::Logging.mdc['request']
44
+ if !old_id.nil? && old_id != restored_id
45
+ action.action_logger.warn('Changing request id %{request_id} to saved id %{saved_id}' % { :saved_id => restored_id, :request_id => old_id })
46
+ end
47
+ ::Logging.mdc['request'] = restored_id
48
+ end
49
+ yield
50
+ ensure
51
+ # Reset to original request id only when not nil
52
+ # Otherwise, keep the id until it's cleaned in Dynflow's run_user_code block
53
+ # so that it will stay valid for the rest of the processing of the current step
54
+ # (even outside of the middleware lifecycle)
55
+ ::Logging.mdc['request'] = old_id unless old_id.nil?
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,36 @@
1
+ require 'base64'
2
+ require 'securerandom'
3
+
4
+ module Proxy::Dynflow
5
+ class OtpManager
6
+ class << self
7
+ def generate_otp(username)
8
+ otp = SecureRandom.hex
9
+ passwords[username] = otp.to_s
10
+ end
11
+
12
+ def drop_otp(username, password)
13
+ passwords.delete(username) if passwords[username] == password
14
+ end
15
+
16
+ def passwords
17
+ @password ||= {}
18
+ end
19
+
20
+ def authenticate(hash, expected_user: nil, clear: true)
21
+ plain = Base64.decode64(hash)
22
+ username, otp = plain.split(':', 2)
23
+ if expected_user
24
+ return false unless expected_user == username
25
+ end
26
+ password_matches = passwords[username] == otp
27
+ passwords.delete(username) if clear && password_matches
28
+ password_matches
29
+ end
30
+
31
+ def tokenize(username, password)
32
+ Base64.strict_encode64("#{username}:#{password}")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,30 +2,25 @@ require 'proxy/log'
2
2
  require 'proxy/pluggable'
3
3
  require 'proxy/plugin'
4
4
 
5
- class Proxy::Dynflow
5
+ module Proxy::Dynflow
6
6
  class Plugin < Proxy::Plugin
7
7
  rackup_path = File.expand_path('http_config.ru', __dir__)
8
8
  http_rackup_path rackup_path
9
9
  https_rackup_path rackup_path
10
10
 
11
11
  settings_file "dynflow.yml"
12
- requires :foreman_proxy, ">= 1.12.0"
13
- default_settings :core_url => 'http://localhost:8008'
12
+ requires :foreman_proxy, ">= 1.16.0"
13
+ default_settings :console_auth => true,
14
+ :execution_plan_cleaner_age => 60 * 60 * 24
14
15
  plugin :dynflow, Proxy::Dynflow::VERSION
15
16
 
16
17
  after_activation do
17
- # Ensure the core gem is loaded, if configure NOT to use the external core
18
- if Proxy::Dynflow::Plugin.settings.external_core == false && !internal_core_available?
19
- raise "'smart_proxy_dynflow_core' gem is required, but not available"
20
- end
21
- end
18
+ require 'smart_proxy_dynflow/settings_loader'
19
+ require 'smart_proxy_dynflow/otp_manager'
20
+ require 'smart_proxy_dynflow/action'
21
+ require 'smart_proxy_dynflow/task_launcher'
22
22
 
23
- def self.internal_core_available?
24
- @core_available ||= begin
25
- require 'smart_proxy_dynflow_core'
26
- true
27
- rescue LoadError # rubocop:disable Lint/HandleExceptions
28
- end
23
+ Proxy::Dynflow::Core.ensure_initialized
29
24
  end
30
25
  end
31
26
  end