foreman_openbolt 1.2.0 → 1.3.0
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 +4 -4
- data/README.md +1 -0
- data/Rakefile +32 -0
- data/app/controllers/api/v2/openbolt_jobs_controller.rb +77 -0
- data/app/controllers/api/v2/openbolt_tasks_controller.rb +83 -0
- data/app/controllers/concerns/foreman_openbolt/common.rb +121 -0
- data/app/controllers/concerns/foreman_openbolt/jobs.rb +84 -0
- data/app/controllers/concerns/foreman_openbolt/tasks.rb +140 -0
- data/app/controllers/foreman_openbolt/task_controller.rb +12 -291
- data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +4 -2
- data/app/lib/actions/foreman_openbolt/poll_task_status.rb +48 -29
- data/app/models/foreman_openbolt/task_job.rb +9 -2
- data/config/routes.rb +39 -4
- data/db/migrate/20260824000000_remove_view_smart_proxies_openbolt_permission.rb +18 -0
- data/lib/foreman_openbolt/engine.rb +15 -7
- data/lib/foreman_openbolt/version.rb +1 -1
- data/lib/proxy_api/openbolt.rb +64 -13
- data/package.json +1 -1
- data/test/acceptance/acceptance_helper.rb +13 -0
- data/test/acceptance/api_acceptance_helper.rb +109 -0
- data/test/acceptance/tests/api/authn_authz_test.rb +82 -0
- data/test/acceptance/tests/api/jobs_test.rb +77 -0
- data/test/acceptance/tests/api/launch_test.rb +79 -0
- data/test/acceptance/tests/api/proxy_tasks_test.rb +51 -0
- data/test/unit/controllers/api/v2/openbolt_jobs_controller_test.rb +251 -0
- data/test/unit/controllers/api/v2/openbolt_tasks_controller_test.rb +438 -0
- data/test/unit/controllers/task_controller_test.rb +197 -39
- data/test/unit/docker/Dockerfile +1 -0
- data/test/unit/lib/actions/poll_task_status_test.rb +112 -34
- data/test/unit/lib/proxy_api/openbolt_test.rb +121 -6
- data/test/unit/models/task_job_test.rb +11 -0
- data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +44 -1
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +3 -0
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +5 -4
- data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +1 -1
- data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +4 -1
- data/webpack/src/Components/LaunchTask/index.js +21 -6
- data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +1 -1
- data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +8 -8
- data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +3 -3
- data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +6 -6
- data/webpack/src/Components/TaskHistory/index.js +3 -3
- metadata +17 -4
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'foreman/logging'
|
|
3
4
|
require 'foreman_openbolt/engine'
|
|
4
5
|
require 'proxy_api/openbolt'
|
|
5
6
|
|
|
6
7
|
module ForemanOpenbolt
|
|
7
|
-
class TaskController <
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
class TaskController < ApplicationController
|
|
9
|
+
# Rails checks rescue_from handlers in reverse registration order (last
|
|
10
|
+
# registered is checked first). The StandardError catch-all must be
|
|
11
|
+
# registered BEFORE the includes so that the specific handlers registered
|
|
12
|
+
# by the other concerns' includes are checked first.
|
|
13
|
+
rescue_from StandardError do |error|
|
|
14
|
+
Foreman::Logging.exception('OpenBolt UI unexpected error', error)
|
|
15
|
+
render_json_error('Internal server error', :internal_server_error)
|
|
16
|
+
end
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
]
|
|
18
|
-
before_action :load_openbolt_api, only: [
|
|
19
|
-
:fetch_tasks, :reload_tasks, :fetch_openbolt_options, :launch_task
|
|
20
|
-
]
|
|
21
|
-
before_action :load_task_job, only: [:job_status, :job_result]
|
|
18
|
+
include ForemanOpenbolt::Jobs
|
|
19
|
+
include ForemanOpenbolt::Tasks
|
|
22
20
|
|
|
23
21
|
# React-rendered pages
|
|
24
22
|
def page_launch_task
|
|
@@ -32,282 +30,5 @@ module ForemanOpenbolt
|
|
|
32
30
|
def page_task_history
|
|
33
31
|
render 'foreman_openbolt/react_page'
|
|
34
32
|
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
|
-
# For encrypted settings, show the placeholder only if a non-empty value
|
|
49
|
-
# has been saved, so the UI shows an empty field for unconfigured passwords
|
|
50
|
-
# instead of a misleading placeholder.
|
|
51
|
-
defaults = {}
|
|
52
|
-
openbolt_settings.each do |setting|
|
|
53
|
-
key = setting.name.sub(/^openbolt_/, '')
|
|
54
|
-
if setting.encrypted?
|
|
55
|
-
defaults[key] = ENCRYPTED_PLACEHOLDER unless setting.value.to_s.empty?
|
|
56
|
-
elsif !setting.value.to_s.empty?
|
|
57
|
-
defaults[key] = setting.value
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Merge the defaults into the options metadata
|
|
62
|
-
result = {}
|
|
63
|
-
options.each do |name, meta|
|
|
64
|
-
result[name] = meta.dup
|
|
65
|
-
result[name]['default'] = defaults[name] if defaults.key?(name)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
render json: result
|
|
69
|
-
rescue ProxyAPI::ProxyException => e
|
|
70
|
-
log_exception('fetch_openbolt_options', e)
|
|
71
|
-
render_error("Smart Proxy error: #{e.message}", :bad_gateway)
|
|
72
|
-
rescue StandardError => e
|
|
73
|
-
log_exception('fetch_openbolt_options', e)
|
|
74
|
-
render_error("Internal server error: #{e.message}", :internal_server_error)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def launch_task
|
|
78
|
-
required_args = [:task_name, :targets]
|
|
79
|
-
missing_args = required_args.select { |arg| params[arg].blank? }
|
|
80
|
-
|
|
81
|
-
if missing_args.any?
|
|
82
|
-
return render_error("Missing required arguments to the launch_task function: #{missing_args.join(', ')}",
|
|
83
|
-
:bad_request)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
begin
|
|
87
|
-
task_name = params[:task_name].to_s.strip
|
|
88
|
-
targets = params[:targets].to_s.strip
|
|
89
|
-
task_params = params[:params] || {}
|
|
90
|
-
options = params[:options] || {}
|
|
91
|
-
options = merge_encrypted_defaults(options)
|
|
92
|
-
|
|
93
|
-
return render_error('Task name and targets cannot be empty', :bad_request) if task_name.empty? || targets.empty?
|
|
94
|
-
|
|
95
|
-
logger.info("Launching OpenBolt task '#{task_name}' on targets '#{targets}' via proxy #{@smart_proxy.name}")
|
|
96
|
-
|
|
97
|
-
response = @openbolt_api.launch_task(
|
|
98
|
-
name: task_name,
|
|
99
|
-
targets: targets,
|
|
100
|
-
parameters: task_params,
|
|
101
|
-
options: options
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
logger.debug("Task execution response: #{response.inspect}")
|
|
105
|
-
|
|
106
|
-
if response['error']
|
|
107
|
-
error_detail = response['error'].is_a?(Hash) ? response['error']['message'] : response['error']
|
|
108
|
-
return render_error("Task execution failed: #{error_detail}", :bad_request)
|
|
109
|
-
end
|
|
110
|
-
return render_error('Task execution failed: No job ID returned', :bad_request) unless response['id']
|
|
111
|
-
|
|
112
|
-
metadata = @openbolt_api.tasks[task_name] || {}
|
|
113
|
-
TaskJob.create_from_execution!(
|
|
114
|
-
proxy: @smart_proxy,
|
|
115
|
-
task_name: task_name,
|
|
116
|
-
task_description: metadata['description'] || '',
|
|
117
|
-
targets: targets.split(',').map(&:strip),
|
|
118
|
-
parameters: task_params,
|
|
119
|
-
options: scrub_options_for_storage(options),
|
|
120
|
-
job_id: response['id']
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
# Start background polling to update status
|
|
124
|
-
ForemanTasks.async_task(Actions::ForemanOpenbolt::PollTaskStatus,
|
|
125
|
-
response['id'],
|
|
126
|
-
@smart_proxy.id)
|
|
127
|
-
|
|
128
|
-
render json: {
|
|
129
|
-
job_id: response['id'],
|
|
130
|
-
}
|
|
131
|
-
rescue ArgumentError => e
|
|
132
|
-
# From merge_encrypted_defaults when a user submits the encrypted
|
|
133
|
-
# placeholder for an option that has no saved Foreman setting.
|
|
134
|
-
log_exception('launch_task', e)
|
|
135
|
-
render_error(e.message, :bad_request)
|
|
136
|
-
rescue ActiveRecord::RecordInvalid => e
|
|
137
|
-
log_exception('launch_task', e)
|
|
138
|
-
render_error("Database error: #{e.message}", :internal_server_error)
|
|
139
|
-
rescue ProxyAPI::ProxyException => e
|
|
140
|
-
log_exception('launch_task', e)
|
|
141
|
-
render_error("Smart Proxy error: #{e.message}", :bad_gateway)
|
|
142
|
-
rescue StandardError => e
|
|
143
|
-
log_exception('launch_task', e)
|
|
144
|
-
render_error("Error launching task: #{e.message}", :internal_server_error)
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def job_status
|
|
149
|
-
return render_error('Task job not found', :not_found) unless @task_job
|
|
150
|
-
|
|
151
|
-
render json: {
|
|
152
|
-
status: @task_job.status,
|
|
153
|
-
submitted_at: @task_job.submitted_at,
|
|
154
|
-
completed_at: @task_job.completed_at,
|
|
155
|
-
duration: @task_job.duration,
|
|
156
|
-
task_name: @task_job.task_name,
|
|
157
|
-
task_description: @task_job.task_description,
|
|
158
|
-
task_parameters: @task_job.task_parameters,
|
|
159
|
-
targets: @task_job.targets,
|
|
160
|
-
smart_proxy: {
|
|
161
|
-
id: @task_job.smart_proxy_id,
|
|
162
|
-
name: @task_job.smart_proxy&.name || '(unknown)',
|
|
163
|
-
},
|
|
164
|
-
}
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def job_result
|
|
168
|
-
return render_error('Task job not found', :not_found) unless @task_job
|
|
169
|
-
|
|
170
|
-
render json: {
|
|
171
|
-
status: @task_job.status,
|
|
172
|
-
command: @task_job.command,
|
|
173
|
-
value: @task_job.result,
|
|
174
|
-
log: @task_job.log,
|
|
175
|
-
}
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# List of all task history
|
|
179
|
-
def fetch_task_history
|
|
180
|
-
per_page = [(params[:per_page] || 20).to_i, 100].min
|
|
181
|
-
@task_history = TaskJob.includes(:smart_proxy)
|
|
182
|
-
.recent
|
|
183
|
-
.paginate(page: params[:page], per_page: per_page)
|
|
184
|
-
|
|
185
|
-
render json: {
|
|
186
|
-
results: @task_history.map { |job| serialize_task_job(job) },
|
|
187
|
-
total: @task_history.total_entries,
|
|
188
|
-
page: @task_history.current_page,
|
|
189
|
-
per_page: @task_history.per_page,
|
|
190
|
-
}
|
|
191
|
-
rescue StandardError => e
|
|
192
|
-
log_exception('fetch_task_history', e)
|
|
193
|
-
render_error("Error loading task history: #{e.message}", :internal_server_error)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def load_smart_proxy
|
|
197
|
-
proxy_id = params[:proxy_id]
|
|
198
|
-
if proxy_id.blank?
|
|
199
|
-
render_error('Smart Proxy ID is required', :bad_request)
|
|
200
|
-
return false
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
return true if @smart_proxy && @smart_proxy.id.to_s == proxy_id.to_s
|
|
204
|
-
|
|
205
|
-
@smart_proxy = SmartProxy.authorized(:view_smart_proxies).find_by(id: proxy_id)
|
|
206
|
-
|
|
207
|
-
unless @smart_proxy
|
|
208
|
-
render_error("Smart Proxy with ID #{proxy_id} not found or not authorized", :not_found)
|
|
209
|
-
return false
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
true
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def load_openbolt_api
|
|
216
|
-
return false unless @smart_proxy
|
|
217
|
-
return true if @openbolt_api && @openbolt_api.url == @smart_proxy.url
|
|
218
|
-
|
|
219
|
-
begin
|
|
220
|
-
@openbolt_api = ProxyAPI::Openbolt.new(url: @smart_proxy.url)
|
|
221
|
-
rescue StandardError => e
|
|
222
|
-
log_exception("load_openbolt_api for proxy #{@smart_proxy.name}", e)
|
|
223
|
-
render_error("Failed to connect to Smart Proxy", :bad_gateway)
|
|
224
|
-
return false
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
true
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def load_task_job
|
|
231
|
-
job_id = params[:job_id]
|
|
232
|
-
logger.debug("load_task_job - Job ID: #{job_id}")
|
|
233
|
-
if job_id.blank?
|
|
234
|
-
render_error('Job ID is required', :bad_request)
|
|
235
|
-
return false
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
@task_job = TaskJob.find_by(job_id: job_id)
|
|
239
|
-
logger.debug("load_task_job - Task Job: #{@task_job.inspect}")
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def openbolt_settings
|
|
243
|
-
@openbolt_settings ||= Foreman.settings.select { |s| s.name.start_with?('openbolt_') }
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def encrypted_settings
|
|
247
|
-
openbolt_settings.select(&:encrypted?)
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def merge_encrypted_defaults(options)
|
|
251
|
-
merged = options.dup
|
|
252
|
-
merged.each do |key, value|
|
|
253
|
-
next unless value == ENCRYPTED_PLACEHOLDER
|
|
254
|
-
|
|
255
|
-
saved = Setting["openbolt_#{key}"]
|
|
256
|
-
if saved.nil? || saved.to_s.empty?
|
|
257
|
-
raise ArgumentError,
|
|
258
|
-
"No saved value for encrypted option '#{key}'. Configure it in Administer > Settings or provide a value."
|
|
259
|
-
end
|
|
260
|
-
merged[key] = saved
|
|
261
|
-
end
|
|
262
|
-
merged
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def scrub_options_for_storage(options)
|
|
266
|
-
scrubbed = options.dup
|
|
267
|
-
encrypted_settings.each do |setting|
|
|
268
|
-
option_name = setting.name.sub(/^openbolt_/, '')
|
|
269
|
-
scrubbed[option_name] = REDACTED_PLACEHOLDER if scrubbed.key?(option_name)
|
|
270
|
-
end
|
|
271
|
-
scrubbed
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def render_openbolt_api_call(method_name, **args)
|
|
275
|
-
result = @openbolt_api.send(method_name, **args)
|
|
276
|
-
logger.debug("OpenBolt API call #{method_name} successful for proxy #{@smart_proxy.name}")
|
|
277
|
-
render json: result
|
|
278
|
-
rescue ProxyAPI::ProxyException => e
|
|
279
|
-
log_exception(method_name, e)
|
|
280
|
-
render_error("Smart Proxy error: #{e.message}", :bad_gateway)
|
|
281
|
-
rescue StandardError => e
|
|
282
|
-
log_exception(method_name, e)
|
|
283
|
-
render_error("Internal server error: #{e.message}", :internal_server_error)
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
def log_exception(message, exception)
|
|
287
|
-
logger.error("#{message}: #{exception.class}: #{exception.message}")
|
|
288
|
-
logger.error(exception.backtrace.join("\n")) if exception.backtrace
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def render_error(message, status)
|
|
292
|
-
render json: { error: message }, status: status
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def serialize_task_job(task_job)
|
|
296
|
-
{
|
|
297
|
-
job_id: task_job.job_id,
|
|
298
|
-
task_name: task_job.task_name,
|
|
299
|
-
task_description: task_job.task_description,
|
|
300
|
-
task_parameters: task_job.task_parameters,
|
|
301
|
-
targets: task_job.targets,
|
|
302
|
-
status: task_job.status,
|
|
303
|
-
smart_proxy: {
|
|
304
|
-
id: task_job.smart_proxy_id,
|
|
305
|
-
name: task_job.smart_proxy&.name || '(unknown)',
|
|
306
|
-
},
|
|
307
|
-
submitted_at: task_job.submitted_at,
|
|
308
|
-
completed_at: task_job.completed_at,
|
|
309
|
-
duration: task_job.duration,
|
|
310
|
-
}
|
|
311
|
-
end
|
|
312
33
|
end
|
|
313
34
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'foreman/logging'
|
|
4
|
+
require 'proxy_api/openbolt'
|
|
5
|
+
|
|
3
6
|
module Actions
|
|
4
7
|
module ForemanOpenbolt
|
|
5
8
|
class CleanupProxyArtifacts < Actions::EntryAction
|
|
@@ -19,8 +22,7 @@ module Actions
|
|
|
19
22
|
Rails.logger.debug { "Cleaned up artifacts for job #{input[:job_id]} on proxy #{proxy.name}: #{response}" }
|
|
20
23
|
rescue StandardError => e
|
|
21
24
|
# Don't fail the action if cleanup fails - it's not critical
|
|
22
|
-
|
|
23
|
-
Rails.logger.error(e.backtrace.join("\n")) if e.backtrace
|
|
25
|
+
::Foreman::Logging.exception("Failed to cleanup artifacts for job #{input[:job_id]}", e)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def rescue_strategy
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'proxy_api/openbolt'
|
|
4
|
+
|
|
3
5
|
# This is a Dynflow action that polls the status of an OpenBolt task job
|
|
4
6
|
# from the Smart Proxy. It periodically checks the job status until it is
|
|
5
7
|
# completed, then fetches the final results.
|
|
@@ -42,11 +44,20 @@ module Actions
|
|
|
42
44
|
log("Polling finished for OpenBolt job #{input[:job_id]}")
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
def mark_exception!(task_job, reason)
|
|
48
|
+
previous_status = task_job.status
|
|
49
|
+
task_job.update!(status: 'exception')
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
log(
|
|
52
|
+
"Could not mark TaskJob #{task_job.job_id} as exception after " \
|
|
53
|
+
"#{reason}: #{e.class}: #{e.message}. Row remains " \
|
|
54
|
+
"in '#{previous_status}' state and will not be re-polled.", :error
|
|
55
|
+
)
|
|
56
|
+
log(e.backtrace.join("\n"), :error) if e.backtrace
|
|
57
|
+
end
|
|
48
58
|
|
|
49
|
-
|
|
59
|
+
def completed_status?(status)
|
|
60
|
+
::ForemanOpenbolt::TaskJob::COMPLETED_STATUSES.include?(status)
|
|
50
61
|
end
|
|
51
62
|
|
|
52
63
|
def poll_and_reschedule
|
|
@@ -69,7 +80,7 @@ module Actions
|
|
|
69
80
|
proxy = ::SmartProxy.find_by(id: input[:proxy_id])
|
|
70
81
|
unless proxy
|
|
71
82
|
log("Smart Proxy with ID #{input[:proxy_id]} not found for OpenBolt job #{job_id}", :error)
|
|
72
|
-
|
|
83
|
+
mark_exception!(task_job, 'proxy not found')
|
|
73
84
|
finish
|
|
74
85
|
return
|
|
75
86
|
end
|
|
@@ -77,51 +88,59 @@ module Actions
|
|
|
77
88
|
begin
|
|
78
89
|
api = ::ProxyAPI::Openbolt.new(url: proxy.url)
|
|
79
90
|
|
|
80
|
-
# Fetch current status
|
|
91
|
+
# Fetch current status. ProxyAPI::Openbolt raises ProxyReportedError
|
|
92
|
+
# for a 200 + {"error": ...} result, which is handled in the rescue. Transport
|
|
93
|
+
# failures raise plain ProxyException and fall through to the retry loop.
|
|
81
94
|
status_result = api.job_status(job_id: job_id)
|
|
82
95
|
|
|
83
|
-
proxy_error = extract_proxy_error(status_result)
|
|
84
|
-
if proxy_error
|
|
85
|
-
log("Proxy returned error for job #{job_id}: #{proxy_error}", :error)
|
|
86
|
-
task_job.update!(status: 'exception')
|
|
87
|
-
finish
|
|
88
|
-
return
|
|
89
|
-
end
|
|
90
|
-
|
|
91
96
|
unless status_result&.dig('status')
|
|
92
97
|
log("Proxy returned response without status for job #{job_id}: #{status_result.inspect}", :error)
|
|
93
|
-
|
|
98
|
+
mark_exception!(task_job, 'missing status in proxy response')
|
|
94
99
|
finish
|
|
95
100
|
return
|
|
96
101
|
end
|
|
97
102
|
|
|
98
|
-
input[:retry_count] = 0
|
|
99
103
|
new_status = status_result['status']
|
|
100
|
-
if new_status == task_job.status
|
|
101
|
-
log("Poll for OpenBolt job #{job_id}: status=#{new_status}")
|
|
102
|
-
else
|
|
103
|
-
previous_status = task_job.status
|
|
104
|
-
task_job.update!(status: new_status)
|
|
105
|
-
log("OpenBolt job #{job_id} status changed from '#{previous_status}' to '#{new_status}'", :info)
|
|
106
|
-
end
|
|
107
104
|
|
|
108
|
-
#
|
|
109
|
-
|
|
105
|
+
# Fetch the result before updating the completed status. Writing
|
|
106
|
+
# status first would strand the job on a transient result fetch
|
|
107
|
+
# failure, since the next poll would see completed? and finish
|
|
108
|
+
# resultless. retry_count is deliberately not reset here so a
|
|
109
|
+
# persistently failing result endpoint still hits the retry limit.
|
|
110
|
+
if completed_status?(new_status)
|
|
110
111
|
result = api.job_result(job_id: job_id)
|
|
112
|
+
|
|
113
|
+
task_job.update_from_proxy_result!(result) if result.present?
|
|
114
|
+
# Fallback for a blank or statusless result body, so the row
|
|
115
|
+
# never finishes stuck as running.
|
|
116
|
+
task_job.update!(status: new_status) unless task_job.completed?
|
|
117
|
+
|
|
111
118
|
if result.present?
|
|
112
|
-
task_job.update_from_proxy_result!(result)
|
|
113
119
|
log("OpenBolt job #{job_id} completed with status '#{task_job.status}'", :info)
|
|
114
120
|
else
|
|
115
|
-
log("No result returned from proxy for completed OpenBolt job #{job_id}", :error)
|
|
121
|
+
log("No result returned from proxy for completed OpenBolt job #{job_id}; recorded status '#{task_job.status}'", :error)
|
|
116
122
|
end
|
|
117
123
|
finish
|
|
118
124
|
return
|
|
119
125
|
end
|
|
120
126
|
|
|
121
|
-
# Still running
|
|
127
|
+
# Still running. A good status read clears the retry counter.
|
|
128
|
+
input[:retry_count] = 0
|
|
129
|
+
if new_status == task_job.status
|
|
130
|
+
log("Poll for OpenBolt job #{job_id}: status=#{new_status}")
|
|
131
|
+
else
|
|
132
|
+
previous_status = task_job.status
|
|
133
|
+
task_job.update!(status: new_status)
|
|
134
|
+
log("OpenBolt job #{job_id} status changed from '#{previous_status}' to '#{new_status}'", :info)
|
|
135
|
+
end
|
|
136
|
+
|
|
122
137
|
suspend do |suspended_action|
|
|
123
138
|
world.clock.ping(suspended_action, POLL_INTERVAL.from_now.to_time, :poll)
|
|
124
139
|
end
|
|
140
|
+
rescue ::ProxyAPI::Openbolt::ProxyReportedError => e
|
|
141
|
+
log("Proxy reported error for job #{job_id}: #{e.message}", :error)
|
|
142
|
+
mark_exception!(task_job, 'proxy-reported error')
|
|
143
|
+
finish
|
|
125
144
|
rescue StandardError => e
|
|
126
145
|
exception("Error polling task status for job #{job_id}", e)
|
|
127
146
|
|
|
@@ -130,7 +149,7 @@ module Actions
|
|
|
130
149
|
|
|
131
150
|
if retry_count > RETRY_LIMIT
|
|
132
151
|
log("Polling gave up for job #{job_id} after #{retry_count} attempts", :error)
|
|
133
|
-
|
|
152
|
+
mark_exception!(task_job, "retry limit exceeded (#{retry_count} attempts)")
|
|
134
153
|
finish
|
|
135
154
|
return
|
|
136
155
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'foreman/logging'
|
|
4
|
+
|
|
3
5
|
module ForemanOpenbolt
|
|
4
6
|
class TaskJob < ApplicationRecord
|
|
5
7
|
self.table_name = 'openbolt_task_jobs'
|
|
@@ -44,6 +46,10 @@ module ForemanOpenbolt
|
|
|
44
46
|
)
|
|
45
47
|
end
|
|
46
48
|
|
|
49
|
+
def kind
|
|
50
|
+
'task'
|
|
51
|
+
end
|
|
52
|
+
|
|
47
53
|
def completed?
|
|
48
54
|
status.in?(COMPLETED_STATUSES)
|
|
49
55
|
end
|
|
@@ -65,6 +71,8 @@ module ForemanOpenbolt
|
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
transaction do
|
|
74
|
+
# status and command guard on presence so blanks never clobber good
|
|
75
|
+
# values; result and log use key? because empty values are legitimate.
|
|
68
76
|
self.status = proxy_result['status'] if proxy_result['status'].present?
|
|
69
77
|
self.command = proxy_result['command'] if proxy_result['command'].present?
|
|
70
78
|
self.result = proxy_result['value'] if proxy_result.key?('value')
|
|
@@ -102,8 +110,7 @@ module ForemanOpenbolt
|
|
|
102
110
|
job_id)
|
|
103
111
|
Rails.logger.debug { "Scheduled cleanup for job #{job_id} on proxy #{smart_proxy_id}" }
|
|
104
112
|
rescue StandardError => e
|
|
105
|
-
|
|
106
|
-
Rails.logger.error(e.backtrace.join("\n")) if e.backtrace
|
|
113
|
+
Foreman::Logging.exception("Failed to schedule cleanup for job #{job_id}", e)
|
|
107
114
|
end
|
|
108
115
|
end
|
|
109
116
|
end
|
data/config/routes.rb
CHANGED
|
@@ -7,17 +7,52 @@ ForemanOpenbolt::Engine.routes.draw do
|
|
|
7
7
|
get 'page_task_history', to: 'task#page_task_history', as: :page_task_history
|
|
8
8
|
|
|
9
9
|
# API endpoints
|
|
10
|
-
get 'fetch_tasks', to: 'task#
|
|
11
|
-
|
|
12
|
-
get 'fetch_openbolt_options', to: 'task#
|
|
10
|
+
get 'fetch_tasks', to: 'task#tasks'
|
|
11
|
+
post 'reload_tasks', to: 'task#reload_tasks'
|
|
12
|
+
get 'fetch_openbolt_options', to: 'task#task_options'
|
|
13
13
|
post 'launch_task', to: 'task#launch_task'
|
|
14
14
|
get 'job_status', to: 'task#job_status'
|
|
15
15
|
get 'job_result', to: 'task#job_result'
|
|
16
16
|
|
|
17
17
|
# Task job management endpoints
|
|
18
|
-
get 'fetch_task_history', to: 'task#
|
|
18
|
+
get 'fetch_task_history', to: 'task#jobs'
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
Foreman::Application.routes.draw do
|
|
22
22
|
mount ForemanOpenbolt::Engine, at: '/foreman_openbolt'
|
|
23
|
+
|
|
24
|
+
namespace :api, defaults: { format: 'json' } do
|
|
25
|
+
scope '(:apiv)',
|
|
26
|
+
module: :v2,
|
|
27
|
+
defaults: { apiv: 'v2' },
|
|
28
|
+
apiv: /v2/,
|
|
29
|
+
constraints: ApiConstraints.new(version: 2, default: true) do
|
|
30
|
+
scope '/openbolt', as: 'openbolt' do
|
|
31
|
+
# Proxy-scoped task operations
|
|
32
|
+
resources :smart_proxies, only: [] do
|
|
33
|
+
resources :tasks, only: [], controller: 'openbolt_tasks' do
|
|
34
|
+
collection do
|
|
35
|
+
get '/', action: :tasks
|
|
36
|
+
post :reload, action: :reload_tasks
|
|
37
|
+
get :options, action: :task_options
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Launch is kind-specific. Plans will add POST 'launch/plan' later.
|
|
43
|
+
post 'launch/task', to: 'openbolt_tasks#launch_task', as: 'launch_task'
|
|
44
|
+
|
|
45
|
+
# Job queries are kind-agnostic. Reads come from the Foreman DB, populated by PollTaskStatus.
|
|
46
|
+
resources :jobs, only: [], controller: 'openbolt_jobs', param: :job_id do
|
|
47
|
+
collection do
|
|
48
|
+
get '/', action: :jobs
|
|
49
|
+
end
|
|
50
|
+
member do
|
|
51
|
+
get :status, action: :job_status
|
|
52
|
+
get :result, action: :job_result
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
23
58
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Foreman never deletes Permission rows for permissions a plugin no longer
|
|
4
|
+
# registers, so upgrades would keep an orphaned view_smart_proxies_openbolt row.
|
|
5
|
+
# Drop it. The default roles keep proxy visibility through the core
|
|
6
|
+
# view_smart_proxies permission. If for some reason a custom role had the old
|
|
7
|
+
# permission applied and not view_smart_proxies, it would lose the smart proxy
|
|
8
|
+
# view permission and this can be easily fixed. But it's very unlikely anyone
|
|
9
|
+
# has a custom role configured like this.
|
|
10
|
+
class RemoveViewSmartProxiesOpenboltPermission < ActiveRecord::Migration[7.0]
|
|
11
|
+
def up
|
|
12
|
+
Permission.where(name: 'view_smart_proxies_openbolt').destroy_all
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def down
|
|
16
|
+
# The plugin no longer registers the permission so there's nothing to restore.
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -238,15 +238,23 @@ module ForemanOpenbolt
|
|
|
238
238
|
|
|
239
239
|
security_block :foreman_openbolt do
|
|
240
240
|
permission :execute_openbolt,
|
|
241
|
-
{
|
|
242
|
-
:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
{
|
|
242
|
+
:'foreman_openbolt/task' => [
|
|
243
|
+
:page_launch_task, :page_task_execution, :page_task_history,
|
|
244
|
+
:tasks, :reload_tasks, :task_options,
|
|
245
|
+
:launch_task, :job_status, :job_result, :jobs
|
|
246
|
+
],
|
|
247
|
+
:'api/v2/openbolt_tasks' => [
|
|
248
|
+
:tasks, :reload_tasks, :task_options, :launch_task
|
|
249
|
+
],
|
|
250
|
+
:'api/v2/openbolt_jobs' => [
|
|
251
|
+
:jobs, :job_status, :job_result
|
|
252
|
+
],
|
|
253
|
+
}
|
|
247
254
|
end
|
|
248
255
|
|
|
249
|
-
role 'OpenBolt Executor', [:execute_openbolt]
|
|
256
|
+
role 'OpenBolt Executor', [:execute_openbolt, :view_smart_proxies],
|
|
257
|
+
'Role granting permissions to list and launch OpenBolt actions through a smart proxy and to view the resulting jobs'
|
|
250
258
|
add_all_permissions_to_default_roles
|
|
251
259
|
|
|
252
260
|
sub_menu :top_menu, :openbolt,
|