smart_proxy_openbolt 0.0.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.
data/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # Smart Proxy - OpenBolt
2
+
3
+ This plug-in adds support for OpenBolt to Foreman's Smart Proxy.
4
+
5
+ # Things to be aware of
6
+ * Any SSH keys to be used should be readable by the foreman-proxy user.
7
+ * Results are currently stored on disk at /var/logs/foreman-proxy/openbolt by default (configurable in settings). Fetching old results is possible as long as the files stay on disk.
8
+
9
+ ## how to release
10
+
11
+ * bump version in `lib/smart_proxy_openbolt/version.rb`
12
+ * run `CHANGELOG_GITHUB_TOKEN=github_pat... bundle exec rake changelog`
13
+ * create a PR
14
+ * get a review & merge
15
+ * create and push a tag
16
+ * github actions will publish the tag
@@ -0,0 +1 @@
1
+ gem 'smart_proxy_openbolt'
@@ -0,0 +1,90 @@
1
+ require 'json'
2
+ require 'sinatra'
3
+ require 'smart_proxy_openbolt/plugin'
4
+ require 'smart_proxy_openbolt/main'
5
+ require 'smart_proxy_openbolt/error'
6
+
7
+ module Proxy::OpenBolt
8
+
9
+ class Api < ::Sinatra::Base
10
+ include ::Proxy::Log
11
+ helpers ::Proxy::Helpers
12
+
13
+ # Require authentication
14
+ # These require foreman-proxy to be able to read Puppet's certs/CA, which
15
+ # by default are owned by puppet:puppet. Need to have installation figure out
16
+ # the best way to open them to foreman-proxy if we want to use this, I think.
17
+ #authorize_with_trusted_hosts
18
+ #authorize_with_ssl_client
19
+
20
+ # Call reload_tasks at class load so the first call to /tasks
21
+ # is potentially faster (if called after this finishes). Do it
22
+ # async so we don't block. The reload_tasks function uses a mutex
23
+ # so it will be safe to call /tasks before it completes.
24
+ Thread.new { Proxy::OpenBolt.tasks }
25
+
26
+ get '/tasks' do
27
+ catch_errors { Proxy::OpenBolt.tasks.to_json }
28
+ end
29
+
30
+ get '/tasks/reload' do
31
+ catch_errors { Proxy::OpenBolt.tasks(reload: true).to_json }
32
+ end
33
+
34
+ get '/tasks/options' do
35
+ catch_errors { Proxy::OpenBolt.openbolt_options.to_json}
36
+ end
37
+
38
+ post '/launch/task' do
39
+ catch_errors do
40
+ data = JSON.parse(request.body.read)
41
+ Proxy::OpenBolt.launch_task(data)
42
+ end
43
+ end
44
+
45
+ get '/job/:id/status' do |id|
46
+ catch_errors { Proxy::OpenBolt.get_status(id) }
47
+ end
48
+
49
+ get '/job/:id/result' do |id|
50
+ catch_errors { Proxy::OpenBolt.get_result(id) }
51
+ end
52
+
53
+ delete '/job/:id/artifacts' do |id|
54
+ catch_errors do
55
+ # Validate the job ID format to prevent directory traversal
56
+ unless id =~ /\A[a-f0-9\-]+\z/i
57
+ raise Proxy::OpenBolt::Error.new(message: "Invalid job ID format")
58
+ end
59
+
60
+ file_path = File.join(Proxy::OpenBolt::Plugin.settings.log_dir, "#{id}.json")
61
+
62
+ if File.exist?(file_path)
63
+ real_path = File.realpath(file_path)
64
+ expected_dir = File.realpath(Proxy::OpenBolt::Plugin.settings.log_dir)
65
+ raise Proxy::OpenBolt::Error.new(message: "Invalid file path") unless real_path.start_with?(expected_dir)
66
+
67
+ File.delete(file_path)
68
+ logger.info("Deleted artifacts for job #{id}")
69
+ { status: 'deleted', job_id: id, path: file_path }.to_json
70
+ else
71
+ logger.warning("Artifacts not found for job #{id}")
72
+ { status: 'not_found', job_id: id }.to_json
73
+ end
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def catch_errors(&block)
80
+ begin
81
+ yield
82
+ rescue Proxy::OpenBolt::Error => e
83
+ e.to_json
84
+ rescue Exception => e
85
+ raise e
86
+ #Proxy::OpenBolt::Error.new(message: "Unhandled exception", exception: e).to_json
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,43 @@
1
+ module Proxy::OpenBolt
2
+ class Error < StandardError
3
+ def initialize(**fields)
4
+ fields.each { |key, val| instance_variable_set("@#{key}", val) }
5
+ super(fields[:message])
6
+ end
7
+
8
+ def to_json
9
+ details = {}
10
+ instance_variables.each do |var|
11
+ name = var.to_s.delete("@").to_sym
12
+ val = instance_variable_get(var)
13
+
14
+ next if val.nil?
15
+
16
+ if name == :exception && val.is_a?(Exception)
17
+ details[:exception] = {
18
+ class: val.class.to_s,
19
+ message: val.message,
20
+ backtrace: val.backtrace
21
+ }
22
+ else
23
+ details[name] = val
24
+ end
25
+ end
26
+ { error: details }.to_json
27
+ end
28
+ end
29
+
30
+ class CliError < Error
31
+ attr_accessor :exitcode, :stdout, :stderr, :command
32
+
33
+ def initialize(message:, exitcode:, stdout:, stderr:, command:)
34
+ super(
35
+ message: message,
36
+ exitcode: exitcode,
37
+ stdout: stdout,
38
+ stderr: stderr,
39
+ command: command,
40
+ )
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,91 @@
1
+ require 'concurrent'
2
+ require 'securerandom'
3
+ require 'singleton'
4
+ require 'smart_proxy_openbolt/job'
5
+ require 'smart_proxy_openbolt/task_job'
6
+
7
+ module Proxy::OpenBolt
8
+ class Executor
9
+ include Singleton
10
+
11
+ SHUTDOWN_TIMEOUT = 30
12
+
13
+ def initialize
14
+ @pool = Concurrent::FixedThreadPool.new(Proxy::OpenBolt::Plugin.settings.workers.to_i)
15
+ @jobs = Concurrent::Map.new
16
+ end
17
+
18
+ def add_job(job)
19
+ raise ArgumentError, "Only Job instances can be added" unless job.is_a?(Job)
20
+ id = SecureRandom.uuid
21
+ job.id = id
22
+ @jobs[id] = job
23
+ @pool.post { job.process }
24
+ id
25
+ end
26
+
27
+ def status(id)
28
+ job = get_job(id)
29
+ return :invalid unless job
30
+ job&.status
31
+ end
32
+
33
+ def result(id)
34
+ job = get_job(id)
35
+ return :invalid unless job
36
+ job.result
37
+ end
38
+
39
+ # How many workers are currently busy
40
+ def num_running
41
+ @pool.length
42
+ end
43
+
44
+ # How many jobs are waiting in the queue
45
+ def queue_length
46
+ @pool.queue_length
47
+ end
48
+
49
+ # Total number of jobs completed since proxy start
50
+ def jobs_completed
51
+ @pool.completed_task_count
52
+ end
53
+
54
+ # Still accepting and running jobs, or shutting down?
55
+ def running?
56
+ @pool.running?
57
+ end
58
+
59
+ # Stop accepting tasks and wait up to SHUTDOWN_TIMEOUT seconds
60
+ # for in-flight jobs to finish. If timeout = nil, wait forever.
61
+ def shutdown(timeout)
62
+ @pool.shutdown
63
+ @pool.wait_for_termination(SHUTDOWN_TIMEOUT)
64
+ end
65
+
66
+ private
67
+
68
+ def get_job(id)
69
+ return @jobs[id] if @jobs.keys.include?(id)
70
+ # Look on disk for a past run that may have happened
71
+ job = nil
72
+ file = "#{Proxy::OpenBolt::Plugin.settings.log_dir}/#{id}.json"
73
+ if File.exist?(file)
74
+ begin
75
+ data = JSON.parse(File.read(file))
76
+ return nil if data['schema'].nil? || data['schema'] != 1
77
+ return nil if data['status'].nil?
78
+ # This is only for reading back status and result. Don't try
79
+ # to fill in the other arguments correctly, and don't assume
80
+ # they are there after execution.
81
+ job = Job.new(nil, nil, nil)
82
+ job.id = id
83
+ job.update_status(data['status'].to_sym)
84
+ @jobs[id] = job
85
+ rescue JSON::ParserError
86
+ end
87
+ end
88
+ job
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ require 'smart_proxy_openbolt/api'
2
+
3
+ map '/openbolt' do
4
+ run Proxy::OpenBolt::Api
5
+ end
@@ -0,0 +1,131 @@
1
+ require 'net/http'
2
+ require 'smart_proxy_openbolt/result'
3
+ require 'thread'
4
+
5
+ module Proxy::OpenBolt
6
+ class Job
7
+ attr_accessor :id
8
+ attr_reader :name, :parameters, :options, :status
9
+
10
+ # Valid statuses are
11
+ # :pending - waiting to run
12
+ # :running - in progress
13
+ # :success - job finished as was completely successful
14
+ # :failure - job finished and had one or more failures
15
+ # :exception - command exited with an unexpected code
16
+
17
+ def initialize(name, parameters, options)
18
+ @id = nil
19
+ @name = name
20
+ @parameters = parameters
21
+ @options = options
22
+ @status = :pending
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def execute
27
+ raise NotImplementedError, "You must call #execute on a subclass of Job"
28
+ end
29
+
30
+ # Called by worker. The 'execute' function should return a
31
+ # Proxy::OpenBolt::Result object
32
+ def process
33
+ update_status(:running)
34
+ begin
35
+ result = execute
36
+ update_status(result.status)
37
+ store_result(result)
38
+ rescue => e
39
+ # This should never happen, but just in case we made a coding error,
40
+ # expose something in the result.
41
+ update_status(:exception)
42
+ store_result({message: e.full_message, backtrace: e.backtrace})
43
+ end
44
+ end
45
+
46
+ def update_status(value)
47
+ @mutex.synchronize { @status = value }
48
+ end
49
+
50
+ def store_result(value)
51
+ # On disk
52
+ results_file = "#{Proxy::OpenBolt::Plugin.settings.log_dir}/#{@id}.json"
53
+ File.open(results_file, 'w') { |f| f.write(value.to_json) }
54
+
55
+ # Send to reports API
56
+ #reports = get_reports(value)
57
+
58
+ # TODO: Figure out how to authenticate with the /api/config_reports endpoint
59
+ #reports.each do |report|
60
+ # foreman = Proxy::SETTINGS.foreman_url
61
+ # Send it
62
+ # puts foreman
63
+ #end
64
+ end
65
+
66
+ def log_item(text, level)
67
+ {
68
+ 'log': {
69
+ 'sources': {
70
+ 'source': 'OpenBolt'
71
+ },
72
+ 'messages': {
73
+ 'message': text
74
+ },
75
+ 'level': level
76
+ }
77
+ }
78
+ end
79
+
80
+ def get_reports(value)
81
+ command = value.command
82
+ log = value.log
83
+ items = value.value['items']
84
+ return nil if items.nil?
85
+ timestamp = Time.now.utc
86
+
87
+ source = { 'sources': { 'source': 'OpenBolt' } }
88
+ items.map do |item|
89
+ target = item['target']
90
+ status = item['status']
91
+ data = item['value']
92
+ message = item['message']
93
+ logs = [log_item("Command: #{command}", 'info')]
94
+ if data['_error']
95
+ kind = data.dig('_error','kind')
96
+ msg = data.dig('_error', 'msg')
97
+ issue_code = data.dig('_error', 'issue_code')
98
+ logs << log_item("Error kind: #{kind}", 'error')
99
+ logs << log_item("Error mesage: #{msg}", 'error')
100
+ logs << log_item("Error issue code: #{issue_code}", 'error')
101
+ end
102
+ logs << log_item("Result: #{data}", 'info')
103
+ logs << log_item("Task run log: #{log}", 'info') if log
104
+ logs << log_item("Message: #{message}", 'info') if message
105
+ {
106
+ 'config_report': {
107
+ 'host': target,
108
+ 'reported_at': timestamp,
109
+ 'status': {
110
+ "applied": status == 'success' ? 1 : 0,
111
+ "failed": status == 'failure' ? 1 : 0,
112
+ },
113
+ 'logs': logs
114
+ }
115
+ }
116
+ end
117
+ items
118
+ end
119
+
120
+
121
+
122
+
123
+ # At the moment, always read back from the file so we don't store a bunch
124
+ # of huge results in memory. Once we are database-backed, this is less
125
+ # cumbersome and problematic.
126
+ def result
127
+ results_file = "#{Proxy::OpenBolt::Plugin.settings.log_dir}/#{@id}.json"
128
+ JSON.parse(File.read(results_file))
129
+ end
130
+ end
131
+ end