executo 0.3.12

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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Executo
4
+ class Command
5
+ include CommandDsl
6
+ include TaggedLogger
7
+
8
+ attr_reader :executo_id, :parameter_values, :status, :stdout, :stderr, :exitstatus
9
+
10
+ def initialize(*args)
11
+ @executo_id = args.first&.delete(:id) || SecureRandom.uuid
12
+ @errors = ActiveModel::Errors.new(self)
13
+ @parameter_values = args.first&.delete(:parameter_values) || {}
14
+ super(*args)
15
+ end
16
+
17
+ def call
18
+ raise 'missing target' unless target
19
+
20
+ perform
21
+ end
22
+
23
+ def process_results(results)
24
+ state = results[:state]
25
+ logger.debug("Processing #{state} results")
26
+ public_send(state.to_sym, results) if respond_to?(state.to_sym)
27
+ end
28
+
29
+ def setup_logger(id)
30
+ logger_add_tag(self.class.name)
31
+ logger_add_tag(id)
32
+ end
33
+
34
+ private
35
+
36
+ def perform
37
+ Executo.publish(target: target, command: command, parameters: safe_parameters, feedback: { service: self.class.name, id: executo_id, arguments: attributes.to_h })
38
+ end
39
+
40
+ def safe_parameters
41
+ local_parameter_values = {}
42
+ local_parameter_values = implicit_parameter_values if respond_to?(:implicit_parameter_values)
43
+ local_parameter_values = local_parameter_values.merge(parameter_values)
44
+
45
+ parameters.split.map { |parameter| parameter % local_parameter_values }
46
+ end
47
+
48
+ class << self
49
+ def process_feedback(feedback, results)
50
+ cmd = new(feedback['arguments'].merge(id: feedback['id']))
51
+ cmd.setup_logger(feedback['id'])
52
+ cmd.process_results(results.symbolize_keys)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Executo
4
+ module CommandDsl
5
+ extend ActiveSupport::Concern
6
+ include ActiveAttr::Model
7
+
8
+ delegate :target, :command, :parameters, :feedback_interval, to: :class
9
+
10
+ class_methods do
11
+ def call(*args)
12
+ new(*args).call
13
+ end
14
+
15
+ def target(value = nil)
16
+ @target = value if value.present?
17
+ @target
18
+ end
19
+
20
+ def command(value = nil)
21
+ @command = value if value.present?
22
+ @command
23
+ end
24
+
25
+ def parameters(value = nil)
26
+ @parameters = value if value.present?
27
+ @parameters || ''
28
+ end
29
+
30
+ def feedback_interval(value = nil)
31
+ @feedback_interval = value if value.present?
32
+ @feedback_interval || 10
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Call with:
4
+ # ImapSyncTest.call(mailbox_id: 'abc', parameter_values: {host1: 1, user1: 1, password1: 1, host2: 2, user2: 2, password2: 2})
5
+
6
+ class ImapSyncTest < Executo::Command
7
+ target 'localhost'
8
+ command '/usr/local/bin/imapsync'
9
+ parameters '--dry --host1 %<host1>s --user1 %<user1>s --password1 %<password1>s --host2 %<host2>s --user2 %<user2>s --password2 %<password2>s --logfile %<logfile>s --delete2'
10
+ attribute :mailbox_id
11
+ feedback_interval 10
12
+
13
+ # Callback for 'started' messages. results contain stdout/stderr, atribute mailbox_id will already be set.
14
+ def started(results)
15
+ logger.info "Started #{mailbox_id}"
16
+ end
17
+
18
+ # Process intermediate output from the command. results contain stdout/stderr/runtime_seconds
19
+ def output(results)
20
+ find_current_status(results[:stdout])
21
+ end
22
+
23
+ # Process completed output from the command. results contain stdout/stderr/exitstatus/runtime_seconds
24
+ def completed(results)
25
+ binding.pry
26
+ end
27
+
28
+ # Process failed output from the command. results contain stdout/stderr/exitstatus/runtime_seconds
29
+ def failed(results)
30
+ end
31
+
32
+ # Process final output from the command. results contain runtime_seconds
33
+ def finished(results)
34
+ end
35
+
36
+ private
37
+
38
+ def find_current_status(lines)
39
+ line = lines.reverse.find { |l| l =~ %r[\d+/\d+ msgs left] }
40
+ return unless line
41
+
42
+ match = line.match(%r[(?<current>\d+)/(?<total>\d+) msgs left])
43
+
44
+ logger.info "Processing #{mailbox_id} - #{match[:current]}/#{match[:total]} (#{(match[:current].to_i.to_f / match[:total].to_i).to_i }%)"
45
+ end
46
+
47
+ def implicit_parameter_values
48
+ {
49
+ logfile: "/tmp/imapsync_#{executo_id}.log"
50
+ }
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Executo
4
+ class Configuration
5
+ attr_accessor :redis
6
+ attr_accessor :active_job_redis
7
+ attr_writer :logger
8
+
9
+ def initialize
10
+ @redis = {}
11
+ @active_job_redis = {}
12
+ @logger = ActiveSupport::TaggedLogging.new(Logger.new($stdout))
13
+ @logger.level = Logger::INFO
14
+ end
15
+
16
+ # logger [Object].
17
+ def logger
18
+ @logger.is_a?(Proc) ? instance_exec(&@logger) : @logger
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'executo/worker'
4
+
5
+ module Executo
6
+ class EncryptedWorker < Worker
7
+ # @param [String] encrypted_command
8
+ # @param [Array] encrypted_params
9
+ # @param [Hash] encrypted_options
10
+ def perform(encrypted_command, encrypted_params = [], encrypted_options = {})
11
+ Executo.config.logger.debug "encrypted_command: #{encrypted_command}"
12
+ Executo.config.logger.debug "encrypted_params: #{encrypted_params}"
13
+ Executo.config.logger.debug "encrypted_options: #{encrypted_options}"
14
+
15
+ command = Executo.decrypt(encrypted_command)
16
+ params = Executo.decrypt(encrypted_params)
17
+ options = Executo.decrypt(encrypted_options)
18
+
19
+ super(command, params, options)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Executo
4
+ class FeedbackProcessJob < ActiveJob::Base
5
+ def perform(feedback, results)
6
+ feedback_service_class = feedback['service']&.safe_constantize
7
+ unless feedback_service_class
8
+ Executo.logger.error("Feedback service #{feedback['service']} not found")
9
+ return
10
+ end
11
+
12
+ feedback_service_class.process_feedback(feedback, results)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Executo
4
+ class FeedbackProcessService
5
+ include Executo::TaggedLogger
6
+
7
+ attr_reader :id, :state, :exitstatus, :stdout, :stderr
8
+ attr_writer :arguments
9
+
10
+ def initialize(feedback, results)
11
+ @id = feedback['id']
12
+ @state = results['state']
13
+ @exitstatus = results['exitstatus']
14
+ @stdout = results['stdout'] || []
15
+ @stderr = results['stderr'] || []
16
+ @arguments = feedback['arguments'] || {}
17
+ end
18
+
19
+ def call
20
+ logger_add_tag(self.class.name)
21
+ logger_add_tag(id)
22
+ perform
23
+ end
24
+
25
+ private
26
+
27
+ def perform; end
28
+
29
+ class << self
30
+ def arguments(*names)
31
+ names.each do |name|
32
+ define_method(name) { instance_variable_get('@arguments')[name.to_s] }
33
+ end
34
+ end
35
+
36
+ def process_feedback(feedback, results)
37
+ new(feedback, results).call
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # https://gist.github.com/jordinl83/08ad9afd8f5046ddd9d38bcebf373e74
4
+
5
+ module Executo
6
+ class SchedulerWorker
7
+ include Sidekiq::Worker
8
+ sidekiq_options queue: 'critical'
9
+
10
+ def perform
11
+ execution_time = Time.now.utc
12
+ execution_time -= execution_time.sec
13
+
14
+ self.class.perform_at(execution_time + 60) unless scheduled?
15
+
16
+ # SCHEDULE.each do |(worker_class, schedule_lambda)|
17
+ # worker_class.perform_async if !scheduled?(worker_class) && schedule_lambda.call(execution_time)
18
+ # end
19
+ end
20
+
21
+ def scheduled?(worker_class = self.class)
22
+ scheduled_workers[worker_class.name]
23
+ end
24
+
25
+ private
26
+
27
+ def scheduled_workers
28
+ @scheduled_workers ||= Sidekiq::ScheduledSet.new.entries.each_with_object({}) do |item, hash|
29
+ hash[item['class']] = true
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Executo
4
+ module TaggedLogger
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ def logger_add_tag(tag)
9
+ return unless tag.present?
10
+
11
+ @logger = logger.tagged(tag)
12
+ end
13
+
14
+ def logger
15
+ @logger ||= Executo.logger
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Executo
4
+ VERSION = '0.3.12'
5
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Executo
4
+ class Worker
5
+ include Sidekiq::Worker
6
+ include Executo::TaggedLogger
7
+
8
+ attr_reader :options, :output, :flushable_output
9
+
10
+ # @param [String] command
11
+ # @param [Array] params
12
+ # @param [Hash] options
13
+ def perform(command, params = [], options = {})
14
+ @options = options
15
+ @output ||= Hash.new([])
16
+ @flushable_output ||= Hash.new([])
17
+ @started_at = Time.current
18
+ @flushed_at = Time.current
19
+
20
+ logger_add_tag('Worker')
21
+ logger_add_tag(options.dig('feedback', 'id'))
22
+ logger_add_tag(command)
23
+
24
+ logger.debug "params: #{params}"
25
+ logger.debug "options: #{options}"
26
+
27
+ send_feedback(state: 'started')
28
+
29
+ status = execute(command, params, options)
30
+ send_feedback(
31
+ state: status.success? ? 'completed' : 'failed',
32
+ exitstatus: status.exitstatus.to_i,
33
+ stdout: output[:stdout],
34
+ stderr: output[:stderr],
35
+ pid: status.pid
36
+ )
37
+ rescue StandardError => e
38
+ logger.error "Exception: #{e.class} - #{e.message}"
39
+ logger.error e.backtrace.join("\n")
40
+ send_feedback(state: 'failed')
41
+ ensure
42
+ send_feedback(state: 'finished')
43
+ end
44
+
45
+ private
46
+
47
+ def execute(command, params = [], options = {})
48
+ argument_list = [command] + params
49
+ stdin_content = options['stdin_content'] || []
50
+ stdin_newlines = options.key?('stdin_newlines') ? options['stdin_newlines'] : true
51
+ shell_escape = options.key?('shell_escape') ? options['shell_escape'] : true
52
+
53
+ CLI.run(argument_list, stdout: ->(line) { register_output(:stdout, line) }, stderr: ->(line) { register_output(:stderr, line) }, stdin_content: stdin_content, stdin_newlines: stdin_newlines, shell_escape: shell_escape)
54
+ end
55
+
56
+ def register_output(channel, value)
57
+ return unless value
58
+
59
+ value = value.split("\n").map { |line| line.chomp.force_encoding('utf-8') }
60
+ output[channel] += value
61
+ flushable_output[channel] += value
62
+ logger.debug "Output: #{channel}: #{value}"
63
+ flush_if_needed
64
+ end
65
+
66
+ def flush_if_needed
67
+ return if time_since_flushed < (options.dig('feedback', 'flush_interval') || 10)
68
+ return if flushable_output[:stdout].blank? && flushable_output[:stderr].blank?
69
+
70
+ flush_stdout = flushable_output[:stdout].dup
71
+ flushable_output[:stdout].clear
72
+ flush_stderr = flushable_output[:stderr].dup
73
+ flushable_output[:stderr].clear
74
+
75
+ send_feedback(state: 'output', stdout: flush_stdout, stderr: flush_stderr)
76
+ @flushed_at = Time.current
77
+ end
78
+
79
+ def time_since_flushed
80
+ (Time.current - @flushed_at).to_i
81
+ end
82
+
83
+ def send_feedback(results)
84
+ results.merge!(runtime_seconds: (Time.current - @started_at).to_i)
85
+ logger.info "Command #{results[:state]} after #{results[:runtime_seconds]} seconds"
86
+ Sidekiq::Client.new(Executo.active_job_connection_pool).push(
87
+ 'class' => ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper,
88
+ 'queue' => 'default',
89
+ 'wrapped' => 'Executo::FeedbackProcessJob',
90
+ 'args' => [
91
+ {
92
+ 'job_class' => 'Executo::FeedbackProcessJob',
93
+ 'arguments' => [
94
+ options['feedback'],
95
+ results.deep_stringify_keys
96
+ ]
97
+ }
98
+ ]
99
+ )
100
+ end
101
+ end
102
+ end
data/lib/executo.rb ADDED
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/message_encryptor'
4
+ require 'active_support/tagged_logging'
5
+ require 'sidekiq'
6
+ require 'active_job'
7
+ require 'securerandom'
8
+ require 'active_attr'
9
+
10
+ require 'executo/cli'
11
+ require 'executo/tagged_logger'
12
+ require 'executo/configuration'
13
+ require 'executo/version'
14
+ require 'executo/encrypted_worker'
15
+ require 'executo/scheduler_worker'
16
+ require 'executo/worker'
17
+ require 'executo/feedback_process_job'
18
+ require 'executo/feedback_process_service'
19
+ require 'executo/command_dsl'
20
+ require 'executo/command'
21
+
22
+ require 'executo/commands/imapsync_test'
23
+
24
+ module Executo
25
+ class Error < StandardError; end
26
+
27
+ class << self
28
+ attr_reader :config
29
+
30
+ delegate :logger, to: :config
31
+
32
+ def setup
33
+ @config = Configuration.new
34
+ yield config
35
+ end
36
+
37
+ def cryptor
38
+ @cryptor ||= ActiveSupport::MessageEncryptor.new(ENV['EXECUTO_KEY'])
39
+ end
40
+
41
+ def encrypt(obj)
42
+ cryptor.encrypt_and_sign(obj)
43
+ end
44
+
45
+ def decrypt(string)
46
+ cryptor.decrypt_and_verify(string)
47
+ end
48
+
49
+ ##
50
+ # Publishes a command to be executed on a target
51
+ #
52
+ # @param [String] target a server or a role
53
+ # @param [String] command command to be executed
54
+ # @param [Array] params params for the command
55
+ # @param [Boolean] encrypt whether to encrypt all parameters
56
+ # @param [Hash] options options for the worker
57
+ # @param [Hash] job_options, options for sidekiq, valid options are:
58
+ # queue - the named queue to use, default 'default'
59
+ # class - the worker class to call, required
60
+ # args - an array of simple arguments to the perform method, must be JSON-serializable
61
+ # at - timestamp to schedule the job (optional), must be Numeric (e.g. Time.now.to_f)
62
+ # retry - whether to retry this job if it fails, default true or an integer number of retries
63
+ # backtrace - whether to save any error backtrace, default false
64
+ def publish(target:, command:, parameters: [], encrypt: false, options: {}, job_options: {}, feedback: {})
65
+ options['feedback'] = feedback&.stringify_keys
66
+ options['feedback']['id'] ||= SecureRandom.uuid
67
+
68
+ args = [command, parameters, options.deep_stringify_keys]
69
+ args = args.map { |a| encrypt(a) } if encrypt
70
+
71
+ sidekiq_options = { 'retry' => 0 }.merge(job_options).merge(
72
+ 'queue' => target,
73
+ 'class' => encrypt ? 'Executo::EncryptedWorker' : 'Executo::Worker',
74
+ 'args' => args
75
+ )
76
+
77
+ if defined?(Rails) && Rails.env.test?
78
+ $executo_jobs ||= {}
79
+ $executo_jobs[options.dig('feedback', 'id')] = sidekiq_options
80
+ else
81
+ Sidekiq::Client.new(connection_pool).push(sidekiq_options)
82
+ end
83
+
84
+ logger.info("Published #{command} to #{target} with id #{options['feedback']['id']}")
85
+ options.dig('feedback', 'id')
86
+ end
87
+
88
+ def schedule(target, list)
89
+ options = {
90
+ 'retry' => 0,
91
+ 'queue' => target,
92
+ 'class' => 'Executo::SetScheduleWorker',
93
+ 'args' => list
94
+ }
95
+ Sidekiq::Client.new(connection_pool).push(options)
96
+ end
97
+
98
+ def connection_pool
99
+ @connection_pool ||= ConnectionPool.new(size: 5, timeout: 5) { Redis.new(config.redis) }
100
+ end
101
+
102
+ def active_job_connection_pool
103
+ @active_job_connection_pool ||= ConnectionPool.new(size: 5, timeout: 5) { Redis.new(config.active_job_redis) }
104
+ end
105
+ end
106
+ end
data/sidekiq.yml ADDED
@@ -0,0 +1,16 @@
1
+ # Sample configuration file for Sidekiq.
2
+ # Options here can still be overridden by cmd line args.
3
+ # Place this file at config/sidekiq.yml and Sidekiq will
4
+ # pick it up automatically.
5
+ ---
6
+ :verbose: false
7
+ :concurrency: 2
8
+
9
+ # Set timeout to 8 on Heroku, longer if you manage your own systems.
10
+ :timeout: 30
11
+
12
+ # Sidekiq will run this file through ERB when reading it so you can
13
+ # even put in dynamic logic, like a host-specific queue.
14
+ # http://www.mikeperham.com/2013/11/13/advanced-sidekiq-host-specific-queues/
15
+ :queues:
16
+ - localhost
data/support/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+ git_source(:entdec) { |repo_name| "git@code.entropydecelerator.com:#{repo_name}.git" }
7
+
8
+ gem 'executo', entdec: 'components/executo', branch: :master
data/support/boot.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'executo'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ Executo.setup do |config|
8
+ config.redis = { url: 'redis://localhost:6379/1' }
9
+ config.callback = lambda do |state, exitstatus, stdout, stderr, context|
10
+ callback_url = context['options'].delete('callback_url')
11
+ break unless callback_url
12
+
13
+ data = {
14
+ state: state,
15
+ command: context['command'],
16
+ params: context['params'],
17
+ options: context['options']
18
+ }
19
+
20
+ data[:exitstatus] = exitstatus.to_i if exitstatus
21
+ data[:stdout] = stdout.force_encoding('utf-8') if stdout
22
+ data[:stderr] = stderr.force_encoding('utf-8') if stderr
23
+ data[:pid] = context['pid'].to_i if context['pid']
24
+
25
+ uri = URI.parse(callback_url)
26
+ http = Net::HTTP.new(uri.host, uri.port)
27
+ http.use_ssl = uri.scheme == 'https'
28
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
29
+ http.start do
30
+ http.request_post(
31
+ uri.path,
32
+ data.to_json,
33
+ 'Content-Type': 'application/json'
34
+ )
35
+ end
36
+ end
37
+ end
38
+
39
+ Sidekiq.configure_server do |config|
40
+ config.redis = Executo.config.redis
41
+ end
@@ -0,0 +1,21 @@
1
+ description "Executo"
2
+
3
+ start on runlevel [2345]
4
+ stop on runlevel [06]
5
+
6
+ env MALLOC_ARENA_MAX=2
7
+
8
+ respawn
9
+ respawn limit 5 10
10
+
11
+ normal exit 0 TERM
12
+ reload signal TSTP
13
+
14
+ script
15
+ exec /bin/bash <<'EOT'
16
+ # Logs to /var/log/upstart/executo.log
17
+ cd /data/executo/
18
+ exec bundle exec sidekiq -r /data/executo/boot.rb -C /data/executo/sidekiq.yml
19
+ EOT
20
+
21
+ end script
@@ -0,0 +1,26 @@
1
+ [Unit]
2
+ Description=executo
3
+ Wants=redis-server.service
4
+ After=multi-user.target redis-server.service
5
+
6
+ [Service]
7
+ Type=simple
8
+ WorkingDirectory=/data/executo
9
+ ExecStart=/usr/local/bin/bundle exec sidekiq -r /data/executo/boot.rb -C /var/apps/our-project/config/sidekiq.yml
10
+ User=pi
11
+ Group=pi
12
+
13
+ # if we crash, restart
14
+ RestartSec=1
15
+ Restart=always
16
+
17
+ # Logs go to syslog
18
+ # View with "sudo journalctl -u executo"
19
+ StandardOutput=syslog
20
+ StandardError=syslog
21
+
22
+ # This will default to "bundler" if we don't specify it
23
+ SyslogIdentifier=executo
24
+
25
+ [Install]
26
+ WantedBy=multi-user.target
@@ -0,0 +1,17 @@
1
+ # Sample configuration file for Sidekiq.
2
+ # Options here can still be overridden by cmd line args.
3
+ # Place this file at config/sidekiq.yml and Sidekiq will
4
+ # pick it up automatically.
5
+ ---
6
+ :verbose: true
7
+ :concurrency: 2
8
+
9
+ # Set timeout to 8 on Heroku, longer if you manage your own systems.
10
+ :timeout: 30
11
+
12
+ # Sidekiq will run this file through ERB when reading it so you can
13
+ # even put in dynamic logic, like a host-specific queue.
14
+ # http://www.mikeperham.com/2013/11/13/advanced-sidekiq-host-specific-queues/
15
+ :queues:
16
+ - g02sj01
17
+
data/test.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'executo'
5
+ require 'active_support/core_ext/hash'
6
+ require 'pry'
7
+
8
+ require 'sidekiq'
9
+ require 'active_job'
10
+
11
+ ActiveJob::Base.queue_adapter = :sidekiq
12
+
13
+ Executo.setup do |config|
14
+ config.redis = { url: 'redis://localhost:6379/1' }
15
+ config.active_job_redis = { url: 'redis://localhost:6379/0' }
16
+ end
17
+
18
+ Executo.publish('localhost', 'ls', ['-al'], feedback: { service: 'LsProcessService', args: { now: Time.now } })
19
+
20
+ puts 'Done'
data/uptime.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'curb'
2
+
3
+ http = Curl.get("http://www.google.com/")
4
+ puts http.body_str