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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +619 -0
  3. data/README.md +46 -0
  4. data/Rakefile +106 -0
  5. data/app/controllers/foreman_openbolt/task_controller.rb +298 -0
  6. data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +40 -0
  7. data/app/lib/actions/foreman_openbolt/poll_task_status.rb +151 -0
  8. data/app/models/foreman_openbolt/task_job.rb +110 -0
  9. data/app/views/foreman_openbolt/react_page.html.erb +1 -0
  10. data/config/routes.rb +24 -0
  11. data/db/migrate/20250819000000_create_openbolt_task_jobs.rb +25 -0
  12. data/db/migrate/20250925000000_add_command_to_openbolt_task_jobs.rb +7 -0
  13. data/db/migrate/20251001000000_add_task_description_to_task_jobs.rb +7 -0
  14. data/db/seeds.d/001_add_openbolt_feature.rb +4 -0
  15. data/lib/foreman_openbolt/engine.rb +169 -0
  16. data/lib/foreman_openbolt/version.rb +5 -0
  17. data/lib/foreman_openbolt.rb +7 -0
  18. data/lib/proxy_api/openbolt.rb +53 -0
  19. data/lib/tasks/foreman_openbolt_tasks.rake +48 -0
  20. data/locale/Makefile +73 -0
  21. data/locale/en/foreman_openbolt.po +19 -0
  22. data/locale/foreman_openbolt.pot +19 -0
  23. data/locale/gemspec.rb +7 -0
  24. data/package.json +41 -0
  25. data/test/factories/foreman_openbolt_factories.rb +7 -0
  26. data/test/test_plugin_helper.rb +8 -0
  27. data/test/unit/foreman_openbolt_test.rb +13 -0
  28. data/webpack/global_index.js +4 -0
  29. data/webpack/global_test_setup.js +11 -0
  30. data/webpack/index.js +19 -0
  31. data/webpack/src/Components/LaunchTask/EmptyContent.js +24 -0
  32. data/webpack/src/Components/LaunchTask/FieldTable.js +147 -0
  33. data/webpack/src/Components/LaunchTask/HostSelector/HostSearch.js +29 -0
  34. data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +208 -0
  35. data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +113 -0
  36. data/webpack/src/Components/LaunchTask/HostSelector/hostgroups.gql +9 -0
  37. data/webpack/src/Components/LaunchTask/HostSelector/hosts.gql +10 -0
  38. data/webpack/src/Components/LaunchTask/HostSelector/index.js +261 -0
  39. data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +116 -0
  40. data/webpack/src/Components/LaunchTask/ParameterField.js +145 -0
  41. data/webpack/src/Components/LaunchTask/ParametersSection.js +66 -0
  42. data/webpack/src/Components/LaunchTask/SmartProxySelect.js +51 -0
  43. data/webpack/src/Components/LaunchTask/TaskSelect.js +84 -0
  44. data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +63 -0
  45. data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +48 -0
  46. data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +64 -0
  47. data/webpack/src/Components/LaunchTask/index.js +333 -0
  48. data/webpack/src/Components/TaskExecution/ExecutionDetails.js +188 -0
  49. data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +99 -0
  50. data/webpack/src/Components/TaskExecution/LoadingIndicator.js +51 -0
  51. data/webpack/src/Components/TaskExecution/ResultDisplay.js +174 -0
  52. data/webpack/src/Components/TaskExecution/TaskDetails.js +99 -0
  53. data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +142 -0
  54. data/webpack/src/Components/TaskExecution/index.js +130 -0
  55. data/webpack/src/Components/TaskHistory/TaskPopover.js +95 -0
  56. data/webpack/src/Components/TaskHistory/index.js +199 -0
  57. data/webpack/src/Components/common/HostsPopover.js +49 -0
  58. data/webpack/src/Components/common/constants.js +44 -0
  59. data/webpack/src/Components/common/helpers.js +19 -0
  60. data/webpack/src/Pages/LaunchTaskPage.js +12 -0
  61. data/webpack/src/Pages/TaskExecutionPage.js +12 -0
  62. data/webpack/src/Pages/TaskHistoryPage.js +12 -0
  63. data/webpack/src/Router/routes.js +30 -0
  64. data/webpack/test_setup.js +17 -0
  65. data/webpack/webpack.config.js +7 -0
  66. 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>