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.
- checksums.yaml +7 -0
- data/LICENSE +675 -0
- data/README.md +16 -0
- data/bundler.d/openbolt.rb +1 -0
- data/lib/smart_proxy_openbolt/api.rb +90 -0
- data/lib/smart_proxy_openbolt/error.rb +43 -0
- data/lib/smart_proxy_openbolt/executor.rb +91 -0
- data/lib/smart_proxy_openbolt/http_config.ru +5 -0
- data/lib/smart_proxy_openbolt/job.rb +131 -0
- data/lib/smart_proxy_openbolt/main.rb +326 -0
- data/lib/smart_proxy_openbolt/plugin.rb +40 -0
- data/lib/smart_proxy_openbolt/result.rb +72 -0
- data/lib/smart_proxy_openbolt/task_job.rb +73 -0
- data/lib/smart_proxy_openbolt/version.rb +5 -0
- data/lib/smart_proxy_openbolt.rb +2 -0
- data/settings.d/openbolt.yml +7 -0
- metadata +76 -0
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,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
|