foreman_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 +619 -0
- data/README.md +46 -0
- data/Rakefile +106 -0
- data/app/controllers/foreman_openbolt/task_controller.rb +298 -0
- data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +40 -0
- data/app/lib/actions/foreman_openbolt/poll_task_status.rb +151 -0
- data/app/models/foreman_openbolt/task_job.rb +110 -0
- data/app/views/foreman_openbolt/react_page.html.erb +1 -0
- data/config/routes.rb +24 -0
- data/db/migrate/20250819000000_create_openbolt_task_jobs.rb +25 -0
- data/db/migrate/20250925000000_add_command_to_openbolt_task_jobs.rb +7 -0
- data/db/migrate/20251001000000_add_task_description_to_task_jobs.rb +7 -0
- data/db/seeds.d/001_add_openbolt_feature.rb +4 -0
- data/lib/foreman_openbolt/engine.rb +169 -0
- data/lib/foreman_openbolt/version.rb +5 -0
- data/lib/foreman_openbolt.rb +7 -0
- data/lib/proxy_api/openbolt.rb +53 -0
- data/lib/tasks/foreman_openbolt_tasks.rake +48 -0
- data/locale/Makefile +73 -0
- data/locale/en/foreman_openbolt.po +19 -0
- data/locale/foreman_openbolt.pot +19 -0
- data/locale/gemspec.rb +7 -0
- data/package.json +41 -0
- data/test/factories/foreman_openbolt_factories.rb +7 -0
- data/test/test_plugin_helper.rb +8 -0
- data/test/unit/foreman_openbolt_test.rb +13 -0
- data/webpack/global_index.js +4 -0
- data/webpack/global_test_setup.js +11 -0
- data/webpack/index.js +19 -0
- data/webpack/src/Components/LaunchTask/EmptyContent.js +24 -0
- data/webpack/src/Components/LaunchTask/FieldTable.js +147 -0
- data/webpack/src/Components/LaunchTask/HostSelector/HostSearch.js +29 -0
- data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +208 -0
- data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +113 -0
- data/webpack/src/Components/LaunchTask/HostSelector/hostgroups.gql +9 -0
- data/webpack/src/Components/LaunchTask/HostSelector/hosts.gql +10 -0
- data/webpack/src/Components/LaunchTask/HostSelector/index.js +261 -0
- data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +116 -0
- data/webpack/src/Components/LaunchTask/ParameterField.js +145 -0
- data/webpack/src/Components/LaunchTask/ParametersSection.js +66 -0
- data/webpack/src/Components/LaunchTask/SmartProxySelect.js +51 -0
- data/webpack/src/Components/LaunchTask/TaskSelect.js +84 -0
- data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +63 -0
- data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +48 -0
- data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +64 -0
- data/webpack/src/Components/LaunchTask/index.js +333 -0
- data/webpack/src/Components/TaskExecution/ExecutionDetails.js +188 -0
- data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +99 -0
- data/webpack/src/Components/TaskExecution/LoadingIndicator.js +51 -0
- data/webpack/src/Components/TaskExecution/ResultDisplay.js +174 -0
- data/webpack/src/Components/TaskExecution/TaskDetails.js +99 -0
- data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +142 -0
- data/webpack/src/Components/TaskExecution/index.js +130 -0
- data/webpack/src/Components/TaskHistory/TaskPopover.js +95 -0
- data/webpack/src/Components/TaskHistory/index.js +199 -0
- data/webpack/src/Components/common/HostsPopover.js +49 -0
- data/webpack/src/Components/common/constants.js +44 -0
- data/webpack/src/Components/common/helpers.js +19 -0
- data/webpack/src/Pages/LaunchTaskPage.js +12 -0
- data/webpack/src/Pages/TaskExecutionPage.js +12 -0
- data/webpack/src/Pages/TaskHistoryPage.js +12 -0
- data/webpack/src/Router/routes.js +30 -0
- data/webpack/test_setup.js +17 -0
- data/webpack/webpack.config.js +7 -0
- metadata +208 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# !/usr/bin/env rake
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'bundler/setup'
|
|
7
|
+
rescue LoadError
|
|
8
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
|
9
|
+
end
|
|
10
|
+
begin
|
|
11
|
+
require 'rdoc/task'
|
|
12
|
+
rescue LoadError
|
|
13
|
+
require 'rdoc/rdoc'
|
|
14
|
+
require 'rake/rdoctask'
|
|
15
|
+
RDoc::Task = Rake::RDocTask
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
|
19
|
+
rdoc.rdoc_dir = 'rdoc'
|
|
20
|
+
rdoc.title = 'ForemanOpenbolt'
|
|
21
|
+
rdoc.options << '--line-numbers'
|
|
22
|
+
rdoc.rdoc_files.include('README.rdoc')
|
|
23
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Bundler::GemHelper.install_tasks
|
|
27
|
+
|
|
28
|
+
require 'rake/testtask'
|
|
29
|
+
|
|
30
|
+
Rake::TestTask.new(:test) do |t|
|
|
31
|
+
t.libs << 'lib'
|
|
32
|
+
t.libs << 'test'
|
|
33
|
+
t.pattern = 'test/**/*_test.rb'
|
|
34
|
+
t.verbose = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
require 'rubocop/rake_task'
|
|
39
|
+
RuboCop::RakeTask.new
|
|
40
|
+
rescue LoadError
|
|
41
|
+
puts 'Rubocop not loaded.'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# This is all kinda screwy. Fix it up later.
|
|
45
|
+
LINTERS = {
|
|
46
|
+
ruby: { cmd: 'rubocop', fix: '--auto-correct' },
|
|
47
|
+
erb: { cmd: 'erb_lint', fix: '--autocorrect', glob: '**/*.erb' },
|
|
48
|
+
js: { image: 'registry.access.redhat.com/ubi9/nodejs-20:latest', cmd: 'npm run lint --', fix: '--fix' },
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
namespace :lint do
|
|
52
|
+
def fix?
|
|
53
|
+
!ENV['FIX'].nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def local?
|
|
57
|
+
!ENV['LOCAL'].nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def bin
|
|
61
|
+
ENV['CONTAINER_BIN'] || 'docker'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
LINTERS.each do |name, cfg|
|
|
65
|
+
desc "Run #{name} linter#{' (fix)' if fix?}"
|
|
66
|
+
task name do
|
|
67
|
+
cmd = [cfg[:cmd]]
|
|
68
|
+
cmd << cfg[:fix] if fix?
|
|
69
|
+
cmd << cfg[:glob] unless cfg[:glob].nil? || cfg[:glob].empty? # rubocop:disable Rails/Blank
|
|
70
|
+
cmd = cmd.join(' ')
|
|
71
|
+
if cfg[:image] && !local?
|
|
72
|
+
cmd = "#{bin} run --rm -v #{Dir.pwd}:/code #{cfg[:image]} /bin/bash -c " +
|
|
73
|
+
"'cd /code && npm install --loglevel=error && #{cmd}'"
|
|
74
|
+
end
|
|
75
|
+
sh cmd
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
desc 'Run all linters'
|
|
80
|
+
task all: LINTERS.keys
|
|
81
|
+
|
|
82
|
+
desc 'Run all linters and apply fixes'
|
|
83
|
+
task :fix do
|
|
84
|
+
ENV['FIX'] = 'true'
|
|
85
|
+
Rake::Task['lint:all'].invoke
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
task default: ['lint:all', 'test']
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
require 'rubygems'
|
|
93
|
+
require 'github_changelog_generator/task'
|
|
94
|
+
|
|
95
|
+
GitHubChangelogGenerator::RakeTask.new :changelog do |config|
|
|
96
|
+
config.exclude_labels = %w[duplicate question invalid wontfix wont-fix skip-changelog github_actions]
|
|
97
|
+
config.user = 'overlookinfra'
|
|
98
|
+
config.project = 'foreman_openbolt'
|
|
99
|
+
gem_version = Gem::Specification.load("#{config.project}.gemspec").version
|
|
100
|
+
config.future_release = gem_version
|
|
101
|
+
end
|
|
102
|
+
rescue LoadError
|
|
103
|
+
task :changelog do
|
|
104
|
+
abort("Run `bundle install --with release` to install the `github_changelog_generator` gem.")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'foreman_openbolt/engine'
|
|
4
|
+
require 'proxy_api/openbolt'
|
|
5
|
+
|
|
6
|
+
module ForemanOpenbolt
|
|
7
|
+
class TaskController < ::ApplicationController
|
|
8
|
+
include ::Foreman::Controller::AutoCompleteSearch
|
|
9
|
+
|
|
10
|
+
# For passing to/from the UI
|
|
11
|
+
ENCRYPTED_PLACEHOLDER = '[Use saved encrypted default]'
|
|
12
|
+
# For saving to the database
|
|
13
|
+
REDACTED_PLACEHOLDER = '*****'
|
|
14
|
+
|
|
15
|
+
before_action :load_smart_proxy, only: [
|
|
16
|
+
:fetch_tasks, :reload_tasks, :fetch_openbolt_options, :launch_task, :job_status, :job_result
|
|
17
|
+
]
|
|
18
|
+
before_action :load_openbolt_api, only: [
|
|
19
|
+
:fetch_tasks, :reload_tasks, :fetch_openbolt_options, :launch_task, :job_status, :job_result
|
|
20
|
+
]
|
|
21
|
+
before_action :load_task_job, only: [:job_status, :job_result]
|
|
22
|
+
|
|
23
|
+
# React-rendered pages
|
|
24
|
+
def page_launch_task
|
|
25
|
+
render 'foreman_openbolt/react_page'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def page_task_execution
|
|
29
|
+
render 'foreman_openbolt/react_page'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def page_task_history
|
|
33
|
+
render 'foreman_openbolt/react_page'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch_tasks
|
|
37
|
+
render_openbolt_api_call(:tasks)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reload_tasks
|
|
41
|
+
render_openbolt_api_call(:reload_tasks)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_openbolt_options
|
|
45
|
+
options = @openbolt_api.openbolt_options
|
|
46
|
+
|
|
47
|
+
# Get defaults from Foreman settings
|
|
48
|
+
defaults = {}
|
|
49
|
+
openbolt_settings.each do |setting|
|
|
50
|
+
key = setting.name.sub(/^openbolt_/, '')
|
|
51
|
+
defaults[key] = setting.encrypted? ? ENCRYPTED_PLACEHOLDER : setting.value if setting.value.present?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Merge the defaults into the options metadata
|
|
55
|
+
result = {}
|
|
56
|
+
options.each do |name, meta|
|
|
57
|
+
result[name] = meta.dup
|
|
58
|
+
result[name]['default'] = defaults[name] if defaults.key?(name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
render json: result
|
|
62
|
+
rescue ProxyAPI::ProxyException => e
|
|
63
|
+
logger.error("OpenBolt API error for fetch_openbolt_options: #{e.message}")
|
|
64
|
+
render_error("Smart Proxy error: #{e.message}", :bad_gateway)
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
logger.error("Unexpected error in fetch_openbolt_options: #{e.class}: #{e.message}")
|
|
67
|
+
render_error("Internal server error: #{e.message}", :internal_server_error)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def launch_task
|
|
71
|
+
required_args = [:task_name, :targets]
|
|
72
|
+
missing_args = required_args.select { |arg| params[arg].blank? }
|
|
73
|
+
|
|
74
|
+
if missing_args.any?
|
|
75
|
+
return render_error("Missing required arguments to the launch_task function: #{missing_args.join(', ')}",
|
|
76
|
+
:bad_request)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
task_name = params[:task_name].to_s.strip
|
|
81
|
+
targets = params[:targets].to_s.strip
|
|
82
|
+
task_params = params[:params] || {}
|
|
83
|
+
options = params[:options] || {}
|
|
84
|
+
options = merge_encrypted_defaults(options)
|
|
85
|
+
|
|
86
|
+
return render_error('Task name and targets cannot be empty', :bad_request) if task_name.empty? || targets.empty?
|
|
87
|
+
|
|
88
|
+
logger.info("Launching OpenBolt task '#{task_name}' on targets '#{targets}' via proxy #{@smart_proxy.name}")
|
|
89
|
+
|
|
90
|
+
response = @openbolt_api.launch_task(
|
|
91
|
+
name: task_name,
|
|
92
|
+
targets: targets,
|
|
93
|
+
parameters: task_params,
|
|
94
|
+
options: options
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
logger.info("Task execution response: #{response.inspect}")
|
|
98
|
+
|
|
99
|
+
return render_error("Task execution failed: #{response['error']}", :bad_request) if response['error']
|
|
100
|
+
return render_error('Task execution failed: No job ID returned', :bad_request) unless response['id']
|
|
101
|
+
|
|
102
|
+
metadata = @openbolt_api.tasks[task_name] || {}
|
|
103
|
+
TaskJob.create_from_execution!(
|
|
104
|
+
proxy: @smart_proxy,
|
|
105
|
+
task_name: task_name,
|
|
106
|
+
task_description: metadata['description'] || '',
|
|
107
|
+
targets: targets.split(',').map(&:strip),
|
|
108
|
+
parameters: task_params,
|
|
109
|
+
options: scrub_options_for_storage(options),
|
|
110
|
+
job_id: response['id']
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Start background polling to update status
|
|
114
|
+
ForemanTasks.async_task(Actions::ForemanOpenbolt::PollTaskStatus,
|
|
115
|
+
response['id'],
|
|
116
|
+
@smart_proxy.id)
|
|
117
|
+
|
|
118
|
+
render json: {
|
|
119
|
+
job_id: response['id'],
|
|
120
|
+
proxy_id: @smart_proxy.id,
|
|
121
|
+
proxy_name: @smart_proxy.name,
|
|
122
|
+
}
|
|
123
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
124
|
+
logger.error("Failed to create TaskJob: #{e.message}")
|
|
125
|
+
render_error("Database error: #{e.message}", :internal_server_error)
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
logger.error("Task launch error: #{e.class}: #{e.message}")
|
|
128
|
+
logger.error("Backtrace: #{e.backtrace.first(5).join("\n")}")
|
|
129
|
+
render_error("Error launching task: #{e.message}", :internal_server_error)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def job_status
|
|
134
|
+
return render_error('Task job not found', :not_found) unless @task_job
|
|
135
|
+
|
|
136
|
+
render json: {
|
|
137
|
+
status: @task_job.status,
|
|
138
|
+
submitted_at: @task_job.submitted_at,
|
|
139
|
+
completed_at: @task_job.completed_at,
|
|
140
|
+
duration: @task_job.duration,
|
|
141
|
+
task_name: @task_job.task_name,
|
|
142
|
+
task_description: @task_job.task_description,
|
|
143
|
+
task_parameters: @task_job.task_parameters,
|
|
144
|
+
targets: @task_job.targets,
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def job_result
|
|
149
|
+
return render_error('Task job not found', :not_found) unless @task_job
|
|
150
|
+
|
|
151
|
+
render json: {
|
|
152
|
+
status: @task_job.status,
|
|
153
|
+
command: @task_job.command,
|
|
154
|
+
value: @task_job.result,
|
|
155
|
+
log: @task_job.log,
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# List of all task history
|
|
160
|
+
def fetch_task_history
|
|
161
|
+
@task_history = TaskJob.includes(:smart_proxy)
|
|
162
|
+
.recent
|
|
163
|
+
.paginate(page: params[:page],
|
|
164
|
+
per_page: (params[:per_page] || 20).to_i)
|
|
165
|
+
|
|
166
|
+
render json: {
|
|
167
|
+
results: @task_history.map { |job| serialize_task_job(job) },
|
|
168
|
+
total: @task_history.total_entries,
|
|
169
|
+
page: @task_history.current_page,
|
|
170
|
+
per_page: @task_history.per_page,
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Show a specific task job
|
|
175
|
+
def show
|
|
176
|
+
task_job = TaskJob.find(params[:id])
|
|
177
|
+
render json: serialize_task_job(task_job, detailed: true)
|
|
178
|
+
rescue ActiveRecord::RecordNotFound
|
|
179
|
+
render_error('Task job not found', :not_found)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def load_smart_proxy
|
|
185
|
+
proxy_id = params[:proxy_id]
|
|
186
|
+
if proxy_id.blank?
|
|
187
|
+
render_error('Smart Proxy ID is required', :bad_request)
|
|
188
|
+
return false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return true if @smart_proxy && @smart_proxy.id.to_s == proxy_id.to_s
|
|
192
|
+
|
|
193
|
+
@smart_proxy = SmartProxy.authorized(:view_smart_proxies).find_by(id: proxy_id)
|
|
194
|
+
|
|
195
|
+
unless @smart_proxy
|
|
196
|
+
render_error("Smart Proxy with ID #{proxy_id} not found or not authorized", :not_found)
|
|
197
|
+
return false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
true
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def load_openbolt_api
|
|
204
|
+
return false unless @smart_proxy
|
|
205
|
+
return true if @openbolt_api && @openbolt_api.url == @smart_proxy.url
|
|
206
|
+
|
|
207
|
+
begin
|
|
208
|
+
@openbolt_api = ProxyAPI::Openbolt.new(url: @smart_proxy.url)
|
|
209
|
+
rescue StandardError => e
|
|
210
|
+
logger.error("Failed to initialize OpenBolt API for proxy #{@smart_proxy.name}: #{e.message}")
|
|
211
|
+
render_error("Failed to connect to Smart Proxy", :bad_gateway)
|
|
212
|
+
return false
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
true
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def load_task_job
|
|
219
|
+
job_id = params[:job_id]
|
|
220
|
+
logger.debug("load_task_job - Job ID: #{job_id}")
|
|
221
|
+
return if job_id.blank?
|
|
222
|
+
|
|
223
|
+
@task_job = TaskJob.find_by(job_id: job_id)
|
|
224
|
+
logger.debug("load_task_job - Task Job: #{@task_job.inspect}")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def openbolt_settings
|
|
228
|
+
@openbolt_settings ||= Setting.where("name LIKE 'openbolt_%'")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def encrypted_settings
|
|
232
|
+
openbolt_settings.select(&:encrypted?)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def merge_encrypted_defaults(options)
|
|
236
|
+
encrypted = options.select { |_, v| v == ENCRYPTED_PLACEHOLDER }
|
|
237
|
+
encrypted.each do |key, _|
|
|
238
|
+
setting = Setting.find_by(name: "openbolt_#{key}")
|
|
239
|
+
raise "Could not find setting called openbolt_#{key}" if setting.nil?
|
|
240
|
+
options[key] = setting.value
|
|
241
|
+
end
|
|
242
|
+
options
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def scrub_options_for_storage(options)
|
|
246
|
+
scrubbed = options.dup
|
|
247
|
+
encrypted_settings.each do |setting|
|
|
248
|
+
option_name = setting.name.sub(/^openbolt_/, '')
|
|
249
|
+
scrubbed[option_name] = REDACTED_PLACEHOLDER if scrubbed.key?(option_name)
|
|
250
|
+
end
|
|
251
|
+
scrubbed
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def render_openbolt_api_call(method_name, **args)
|
|
255
|
+
result = @openbolt_api.send(method_name, **args)
|
|
256
|
+
logger.debug("OpenBolt API call #{method_name} successful for proxy #{@smart_proxy.name}")
|
|
257
|
+
render json: result
|
|
258
|
+
rescue ProxyAPI::ProxyException => e
|
|
259
|
+
logger.error("OpenBolt API error for #{method_name}: #{e.message}")
|
|
260
|
+
render_error("Smart Proxy error: #{e.message}", :bad_gateway)
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
logger.error("Unexpected error in #{method_name}: #{e.class}: #{e.message}")
|
|
263
|
+
render_error("Internal server error: #{e.message}", :internal_server_error)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def render_error(message, status)
|
|
267
|
+
render json: { error: message }, status: status
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def serialize_task_job(task_job, detailed: false)
|
|
271
|
+
data = {
|
|
272
|
+
job_id: task_job.job_id,
|
|
273
|
+
task_name: task_job.task_name,
|
|
274
|
+
task_description: task_job.task_description,
|
|
275
|
+
task_parameters: task_job.task_parameters,
|
|
276
|
+
targets: task_job.targets,
|
|
277
|
+
status: task_job.status,
|
|
278
|
+
smart_proxy: {
|
|
279
|
+
id: task_job.smart_proxy_id,
|
|
280
|
+
name: task_job.smart_proxy.name,
|
|
281
|
+
},
|
|
282
|
+
submitted_at: task_job.submitted_at,
|
|
283
|
+
completed_at: task_job.completed_at,
|
|
284
|
+
duration: task_job.duration,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if detailed
|
|
288
|
+
data.merge!(
|
|
289
|
+
openbolt_options: task_job.scrubbed_openbolt_options,
|
|
290
|
+
result: task_job.result,
|
|
291
|
+
log: task_job.log
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
data
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Actions
|
|
4
|
+
module ForemanOpenbolt
|
|
5
|
+
class CleanupProxyArtifacts < Actions::EntryAction
|
|
6
|
+
def plan(proxy_id, job_id)
|
|
7
|
+
plan_self(proxy_id: proxy_id, job_id: job_id)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run
|
|
11
|
+
proxy = ::SmartProxy.find(input[:proxy_id])
|
|
12
|
+
api = ::ProxyAPI::Openbolt.new(url: proxy.url)
|
|
13
|
+
|
|
14
|
+
response = api.delete_job_artifacts(job_id: input[:job_id])
|
|
15
|
+
Rails.logger.info("Cleaned up artifacts for job #{input[:job_id]}: #{response}")
|
|
16
|
+
|
|
17
|
+
Rails.logger.info("Would delete artifacts for job #{input[:job_id]} on proxy #{proxy.name}")
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
# Don't fail the action if cleanup fails - it's not critical
|
|
20
|
+
Rails.logger.error("Failed to cleanup artifacts for job #{input[:job_id]}: #{e.message}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rescue_strategy
|
|
24
|
+
# Skip rescue - if cleanup fails, we don't want to retry
|
|
25
|
+
Dynflow::Action::Rescue::Skip
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def humanized_name
|
|
29
|
+
_("Cleanup OpenBolt task artifacts")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def humanized_input
|
|
33
|
+
{
|
|
34
|
+
job_id: input[:job_id],
|
|
35
|
+
proxy: ::SmartProxy.find_by(id: input[:proxy_id])&.name,
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This is a Dynflow action that polls the status of an OpenBolt task job
|
|
4
|
+
# from the Smart Proxy. It periodically checks the job status until it is
|
|
5
|
+
# completed, then fetches the final results.
|
|
6
|
+
module Actions
|
|
7
|
+
module ForemanOpenbolt
|
|
8
|
+
class PollTaskStatus < Actions::EntryAction
|
|
9
|
+
include Actions::RecurringAction
|
|
10
|
+
|
|
11
|
+
POLL_INTERVAL = 5.seconds
|
|
12
|
+
RETRY_LIMIT = 60 # Number of retries before giving up (5 minutes)
|
|
13
|
+
|
|
14
|
+
# Set up the action when it is first scheduled, storing
|
|
15
|
+
# IDs needed to get information from the proxy.
|
|
16
|
+
def plan(job_id, proxy_id)
|
|
17
|
+
plan_self(job_id: job_id, proxy_id: proxy_id)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Main execution method that Dynflow will call repeatedly.
|
|
21
|
+
# event = nil when execution starts
|
|
22
|
+
# event = :poll when this is triggered by the timer
|
|
23
|
+
def run(event = nil)
|
|
24
|
+
if event.nil? || event.to_sym == :poll
|
|
25
|
+
poll_and_reschedule
|
|
26
|
+
else
|
|
27
|
+
error("Received unknown event '#{event}' for OpenBolt job #{input[:job_id]}. Finishing the action.")
|
|
28
|
+
finish
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def finalize
|
|
33
|
+
Rails.logger.info("Finalized polling for OpenBolt job #{input[:job_id]}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def append_output(key, message)
|
|
39
|
+
output[key] ||= []
|
|
40
|
+
output[key] << message
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def log(message)
|
|
44
|
+
append_output(:log, "[#{Time.now.getlocal.strftime('%Y-%m-%d %H:%M:%S')}] #{message}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def error(message)
|
|
48
|
+
append_output(:error, message)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def exception(e)
|
|
52
|
+
append_output(:exception, e.message)
|
|
53
|
+
append_output(:exception_backtrace, e.backtrace.join("\n"))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def finish
|
|
57
|
+
log("Polling finished for OpenBolt job #{input[:job_id]}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def poll_and_reschedule
|
|
61
|
+
job_id = input[:job_id]
|
|
62
|
+
|
|
63
|
+
# If task doesn't exist or is already complete, finish
|
|
64
|
+
if task_job.nil? || task_job.completed?
|
|
65
|
+
finish
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# If the smart proxy has been deleted somehow or is unknown,
|
|
70
|
+
# we can't poll for status, so finish.
|
|
71
|
+
if proxy.nil?
|
|
72
|
+
error("Smart Proxy with ID #{input[:proxy_id]} not found for OpenBolt job #{job_id}. Finishing the action.")
|
|
73
|
+
finish
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
api = ::ProxyAPI::Openbolt.new(url: proxy.url)
|
|
79
|
+
|
|
80
|
+
# Fetch current status
|
|
81
|
+
status_result = api.job_status(job_id: job_id)
|
|
82
|
+
|
|
83
|
+
# Update status if changed
|
|
84
|
+
if status_result && status_result['status']
|
|
85
|
+
input[:retry_count] = 0
|
|
86
|
+
task_job.update!(status: status_result['status'])
|
|
87
|
+
log("Status: #{status_result['status']}")
|
|
88
|
+
|
|
89
|
+
# If completed, fetch full results
|
|
90
|
+
if task_job.completed?
|
|
91
|
+
result = api.job_result(job_id: job_id)
|
|
92
|
+
if result
|
|
93
|
+
task_job.update_from_proxy_result!(result)
|
|
94
|
+
log("OpenBolt job #{job_id} completed with status '#{task_job.status}'")
|
|
95
|
+
else
|
|
96
|
+
log("WARNING: No result returned from proxy for completed OpenBolt job #{job_id}")
|
|
97
|
+
end
|
|
98
|
+
finish
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Still running, schedule next poll in 5 seconds
|
|
104
|
+
suspend do |suspended_action|
|
|
105
|
+
world.clock.ping(suspended_action, POLL_INTERVAL.from_now.to_time, :poll)
|
|
106
|
+
end
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
error("Error polling task status for job #{job_id}")
|
|
109
|
+
exception(e)
|
|
110
|
+
|
|
111
|
+
retry_count = (input[:retry_count] || 0) + 1
|
|
112
|
+
input[:retry_count] = retry_count
|
|
113
|
+
|
|
114
|
+
if retry_count > RETRY_LIMIT
|
|
115
|
+
error("Could not successfully poll task status for job #{job_id} after #{retry_count} attempts. Giving up.")
|
|
116
|
+
finish
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
suspend do |suspended_action|
|
|
121
|
+
world.clock.ping(suspended_action, POLL_INTERVAL.from_now.to_time, :poll)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def rescue_strategy
|
|
127
|
+
Dynflow::Action::Rescue::Skip
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def humanized_name
|
|
131
|
+
_('Poll OpenBolt task execution status')
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def humanized_input
|
|
135
|
+
input.slice(:job_id, :proxy_id).merge(
|
|
136
|
+
# Using & to handle possible nil values just in case
|
|
137
|
+
proxy_name: proxy&.name,
|
|
138
|
+
task_name: task_job&.task_name
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def task_job
|
|
143
|
+
@task_job ||= ::ForemanOpenbolt::TaskJob.find_by(job_id: input[:job_id])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def proxy
|
|
147
|
+
@proxy ||= ::SmartProxy.find_by(id: input[:proxy_id])
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ForemanOpenbolt
|
|
4
|
+
class TaskJob < ApplicationRecord
|
|
5
|
+
self.table_name = 'openbolt_task_jobs'
|
|
6
|
+
self.primary_key = 'job_id'
|
|
7
|
+
|
|
8
|
+
# Constants
|
|
9
|
+
STATUSES = %w[pending running success failure exception invalid].freeze
|
|
10
|
+
COMPLETED_STATUSES = %w[success failure exception invalid].freeze
|
|
11
|
+
RUNNING_STATUSES = %w[pending running].freeze
|
|
12
|
+
|
|
13
|
+
# Associations
|
|
14
|
+
belongs_to :smart_proxy
|
|
15
|
+
|
|
16
|
+
# Validations
|
|
17
|
+
validates :task_name, presence: true
|
|
18
|
+
validates :job_id, presence: true, uniqueness: true
|
|
19
|
+
validates :status, inclusion: { in: STATUSES }
|
|
20
|
+
validates :targets, presence: true
|
|
21
|
+
validates :submitted_at, presence: true
|
|
22
|
+
|
|
23
|
+
# Scopes
|
|
24
|
+
scope :running, -> { where(status: RUNNING_STATUSES) }
|
|
25
|
+
scope :completed, -> { where(status: COMPLETED_STATUSES) }
|
|
26
|
+
scope :recent, -> { order(submitted_at: :desc) }
|
|
27
|
+
scope :for_proxy, ->(proxy) { where(smart_proxy: proxy) }
|
|
28
|
+
|
|
29
|
+
# Callbacks
|
|
30
|
+
before_validation :set_submitted_at, on: :create
|
|
31
|
+
before_update :set_completed_at, if: :status_changed_to_completed?
|
|
32
|
+
after_update :cleanup_proxy_artifacts, if: :saved_result_and_log?
|
|
33
|
+
|
|
34
|
+
# Class methods
|
|
35
|
+
def self.create_from_execution!(proxy:, task_name:, task_description:, targets:, job_id:, parameters: {}, options: {})
|
|
36
|
+
create!(
|
|
37
|
+
job_id: job_id,
|
|
38
|
+
smart_proxy: proxy,
|
|
39
|
+
task_name: task_name,
|
|
40
|
+
task_description: task_description,
|
|
41
|
+
targets: targets,
|
|
42
|
+
task_parameters: parameters,
|
|
43
|
+
openbolt_options: options,
|
|
44
|
+
status: 'pending'
|
|
45
|
+
# submitted_at is set by callback
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def completed?
|
|
50
|
+
status.in?(COMPLETED_STATUSES)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def running?
|
|
54
|
+
status.in?(RUNNING_STATUSES)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def duration
|
|
58
|
+
return nil unless submitted_at && completed_at
|
|
59
|
+
completed_at - submitted_at
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Result/log will already be scrubbed by the proxy
|
|
63
|
+
def update_from_proxy_result!(proxy_result)
|
|
64
|
+
return if proxy_result.blank?
|
|
65
|
+
|
|
66
|
+
transaction do
|
|
67
|
+
self.status = proxy_result['status'] if proxy_result['status'].present?
|
|
68
|
+
self.command = proxy_result['command'] if proxy_result['command'].present?
|
|
69
|
+
self.result = proxy_result['value'] if proxy_result.key?('value')
|
|
70
|
+
self.log = proxy_result['log'] if proxy_result.key?('log')
|
|
71
|
+
save!
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def target_count
|
|
76
|
+
targets&.size || 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def formatted_targets
|
|
80
|
+
targets&.join(', ') || ''
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def set_submitted_at
|
|
86
|
+
self.submitted_at ||= Time.current
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def set_completed_at
|
|
90
|
+
self.completed_at ||= Time.current if completed?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def status_changed_to_completed?
|
|
94
|
+
status_changed? && completed?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def saved_result_and_log?
|
|
98
|
+
saved_change_to_result? || saved_change_to_log?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def cleanup_proxy_artifacts
|
|
102
|
+
return unless result.present? && log.present?
|
|
103
|
+
# Schedule cleanup of proxy artifacts if we have successfully saved the results
|
|
104
|
+
# ForemanTasks.async_task(::Actions::ForemanOpenbolt::CleanupProxyArtifacts,
|
|
105
|
+
# smart_proxy_id,
|
|
106
|
+
# job_id)
|
|
107
|
+
# Rails.logger.info("Scheduled cleanup for job #{job_id} on proxy #{smart_proxy_id}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div id="react-app-mount-point"></div>
|