foreman_openbolt 1.2.1 → 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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/controllers/api/v2/openbolt_jobs_controller.rb +77 -0
  4. data/app/controllers/api/v2/openbolt_tasks_controller.rb +83 -0
  5. data/app/controllers/concerns/foreman_openbolt/common.rb +121 -0
  6. data/app/controllers/concerns/foreman_openbolt/jobs.rb +84 -0
  7. data/app/controllers/concerns/foreman_openbolt/tasks.rb +140 -0
  8. data/app/controllers/foreman_openbolt/task_controller.rb +12 -291
  9. data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +4 -2
  10. data/app/lib/actions/foreman_openbolt/poll_task_status.rb +48 -29
  11. data/app/models/foreman_openbolt/task_job.rb +9 -2
  12. data/config/routes.rb +39 -4
  13. data/db/migrate/20260824000000_remove_view_smart_proxies_openbolt_permission.rb +18 -0
  14. data/lib/foreman_openbolt/engine.rb +15 -7
  15. data/lib/foreman_openbolt/version.rb +1 -1
  16. data/lib/proxy_api/openbolt.rb +64 -13
  17. data/package.json +1 -1
  18. data/test/acceptance/api_acceptance_helper.rb +109 -0
  19. data/test/acceptance/tests/api/authn_authz_test.rb +82 -0
  20. data/test/acceptance/tests/api/jobs_test.rb +77 -0
  21. data/test/acceptance/tests/api/launch_test.rb +79 -0
  22. data/test/acceptance/tests/api/proxy_tasks_test.rb +51 -0
  23. data/test/unit/controllers/api/v2/openbolt_jobs_controller_test.rb +251 -0
  24. data/test/unit/controllers/api/v2/openbolt_tasks_controller_test.rb +438 -0
  25. data/test/unit/controllers/task_controller_test.rb +197 -39
  26. data/test/unit/docker/Dockerfile +1 -0
  27. data/test/unit/lib/actions/poll_task_status_test.rb +112 -34
  28. data/test/unit/lib/proxy_api/openbolt_test.rb +121 -6
  29. data/test/unit/models/task_job_test.rb +11 -0
  30. data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +44 -1
  31. data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +3 -0
  32. data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +5 -4
  33. data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +1 -1
  34. data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +4 -1
  35. data/webpack/src/Components/LaunchTask/index.js +21 -6
  36. data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +1 -1
  37. data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +8 -8
  38. data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +3 -3
  39. data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +6 -6
  40. data/webpack/src/Components/TaskHistory/index.js +3 -3
  41. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6aae14c90bc7a4af6435767a84d174b69309e9f98ae59a221477067be2492b8
4
- data.tar.gz: 2d3e9188b01cf3c03bc132440106271b541f8ab753df060984e559b624058631
3
+ metadata.gz: a7444805dbd4902da2b3b411388c28968cc4a893d00d244caacd8ed083cca78e
4
+ data.tar.gz: 90aab4b505c4347d212324094d7d5d73b8a1dcc9148eafa40a2be77e8fdee083
5
5
  SHA512:
6
- metadata.gz: 4f2f8a142ca2e214836b36c5f22be10975c322b5df20830110edc09ef49ac6b1445bccb22ffbe7b6d3252efb140d9ce45b1fa11d12987df27270fbd014e497ca
7
- data.tar.gz: b05c9442560d6acc1720c7495c30fe213f2b59ea72f5aefe37f3b7cab98b863b6c22add8fa3fd010b6b3803075b4c46ad130232c7b804d2dfffd6f4f118f17fe
6
+ metadata.gz: 40e72f42e822e1b4a488a71ef10b203db849ac69978fac1eb400c6be55e6d799262ed37f7dbfe039fded5cf4a2e18255c76bf24af6981d400ae63c6077019349
7
+ data.tar.gz: 05dca9f1a59475878cc3435a1fa8fd21dbc6aee16346bb204a60980eafc3a881b1f431a4ab3d4b9043f92b6a4c46bc5b71129872af1fae5293910f5458a05566
data/README.md CHANGED
@@ -68,6 +68,7 @@ A handful of core/default Tasks & Plans are also included in the [OpenBolt rpm/d
68
68
  ## Documentation
69
69
 
70
70
  - [Usage](docs/usage.md): screenshots and walkthrough of the UI
71
+ - [API](docs/api.md): REST API reference, authentication, and example requests
71
72
  - [Development](docs/development.md): linting, unit tests, acceptance tests, building packages
72
73
  - [Releasing](docs/releasing.md): version locations, release steps, RPM/DEB packaging
73
74
  - [Choria Testing](docs/choria-testing.md): setting up Choria on a Foreman install for transport testing
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api
4
+ module V2
5
+ class OpenboltJobsController < Api::V2::BaseController
6
+ include Api::Version2
7
+ include ForemanOpenbolt::Jobs
8
+
9
+ resource_description do
10
+ resource_id 'openbolt_jobs'
11
+ api_version 'v2'
12
+ api_base_url '/api/v2/openbolt'
13
+ end
14
+
15
+ def resource_class
16
+ ForemanOpenbolt::TaskJob
17
+ end
18
+
19
+ def_param_group :job do
20
+ property :job_id, String, desc: N_('Proxy-issued job ID')
21
+ property :kind, %w[task], desc: N_("Job kind. Currently always 'task', and will include 'plan' at a later date.")
22
+ property :status, ForemanOpenbolt::TaskJob::STATUSES, desc: N_('Status of the job')
23
+ property :submitted_at, Time, desc: N_('When the job was submitted to the proxy')
24
+ property :completed_at, Time, allow_nil: true, desc: N_('When the job completed. Empty while the job is running.')
25
+ property :duration, Float, allow_nil: true, desc: N_('Job duration in seconds. Empty while the job is running.')
26
+ property :name, String, desc: N_('Name of the launched Bolt task (or later, plan)')
27
+ property :description, String, allow_nil: true, desc: N_('Description of the launched Bolt task (or later, plan)')
28
+ property :parameters, Hash, desc: N_('Parameters the job was launched with')
29
+ property :targets, Array, of: String, desc: N_('Target hosts the job was launched against for tasks')
30
+ property :smart_proxy, Hash, desc: N_('Smart proxy that executed the job') do
31
+ property :id, Integer, desc: N_('Smart proxy ID')
32
+ property :name, String, desc: N_('Smart proxy name')
33
+ end
34
+ end
35
+
36
+ api :GET, '/jobs', N_('List of OpenBolt jobs recorded in Foreman')
37
+ param_group :pagination, Api::V2::BaseController
38
+ returns code: 200, desc: N_('Paginated list of jobs') do
39
+ property :total, Integer, desc: N_('Total number of recorded jobs')
40
+ property :page, Integer, desc: N_('Current page number')
41
+ property :per_page, Integer, desc: N_('Number of jobs per page')
42
+ property :results, Array, desc: N_('Job objects for the requested page') do
43
+ param_group :job, ::Api::V2::OpenboltJobsController
44
+ end
45
+ end
46
+ def jobs
47
+ super
48
+ end
49
+
50
+ api :GET, '/jobs/:job_id/status', N_('Get the current status of an OpenBolt job')
51
+ param :job_id, :identifier, required: true, desc: N_('Proxy-issued job ID returned by /launch/task')
52
+ returns :job, code: 200, desc: N_('Current status of the job')
53
+ def job_status
54
+ super
55
+ end
56
+
57
+ api :GET, '/jobs/:job_id/result',
58
+ N_('Get the full result (command, OpenBolt result value, log) of a completed OpenBolt job')
59
+ param :job_id, :identifier, required: true, desc: N_('Proxy-issued job ID returned by /launch/task')
60
+ returns code: 200, desc: N_('Full result of the job') do
61
+ property :kind, %w[task], desc: N_("Job kind. Currently always 'task', and will include 'plan' at a later date.")
62
+ property :status, ForemanOpenbolt::TaskJob::STATUSES, desc: N_('Status of the job')
63
+ property :command, String, allow_nil: true,
64
+ desc: N_('The OpenBolt command the proxy ran, with sensitive option values scrubbed. ' \
65
+ 'Empty until the proxy reports the first status update.')
66
+ property :value, [Hash, String], allow_nil: true,
67
+ desc: N_('Run result as produced by the OpenBolt CLI, passed through unmodified. ' \
68
+ 'Its layout depends on the job kind and the OpenBolt Result format. ' \
69
+ 'On failure or exception, this may instead be a plain string holding the raw output.')
70
+ property :log, String, allow_nil: true, desc: N_('Output log of the run')
71
+ end
72
+ def job_result
73
+ super
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'foreman/logging'
4
+ require 'proxy_api/openbolt'
5
+
6
+ module Api
7
+ module V2
8
+ class OpenboltTasksController < Api::V2::BaseController
9
+ include Api::Version2
10
+ include ForemanOpenbolt::Tasks
11
+
12
+ resource_description do
13
+ resource_id 'openbolt_tasks'
14
+ api_version 'v2'
15
+ api_base_url '/api/v2/openbolt'
16
+ end
17
+
18
+ def_param_group :task_map do
19
+ property :task_name, Hash, required: false,
20
+ desc: N_('One entry per task, keyed by the task name. "task_name" is a placeholder, and the actual keys are the task names.') do
21
+ property :description, String, desc: N_('Description from the task metadata')
22
+ property :parameters, Hash,
23
+ desc: N_('Parameter definitions from the task metadata, keyed by parameter name')
24
+ end
25
+ end
26
+
27
+ api :GET, '/smart_proxies/:smart_proxy_id/tasks', N_('List Bolt tasks available on a smart proxy')
28
+ param :smart_proxy_id, Integer, required: true, desc: N_('ID of the smart proxy to query')
29
+ returns :task_map, code: 200, additional_properties: true,
30
+ desc: N_('Hash keyed by task name. task_name below is a placeholder, and the actual keys are the task names.')
31
+ def tasks
32
+ super
33
+ end
34
+
35
+ api :POST, '/smart_proxies/:smart_proxy_id/tasks/reload', N_("Reload the smart proxy's Bolt task cache")
36
+ param :smart_proxy_id, Integer, required: true, desc: N_('ID of the smart proxy to reload')
37
+ returns :task_map, code: 200, additional_properties: true,
38
+ desc: N_('The refreshed task list, in the same shape as the tasks endpoint response')
39
+ def reload_tasks
40
+ super
41
+ end
42
+
43
+ api :GET, '/smart_proxies/:smart_proxy_id/tasks/options',
44
+ N_('Get OpenBolt options metadata for a smart proxy, with Foreman setting defaults merged in')
45
+ param :smart_proxy_id, Integer, required: true, desc: N_('ID of the smart proxy to query')
46
+ returns code: 200, additional_properties: true,
47
+ desc: N_('Hash keyed by option name. "option_name" is a placeholder, and the actual keys are the option names.') do
48
+ property :option_name, Hash, required: false, desc: N_('One entry per option, keyed by the option name') do
49
+ property :type, [String, Array],
50
+ desc: N_("The option's value type, either a type name or an array of the allowed values")
51
+ property :transport, Array, of: String, desc: N_('Transports the option applies to')
52
+ property :sensitive, [true, false], desc: N_('Whether the value is scrubbed from recorded commands and logs')
53
+ property :description, String, desc: N_('Human readable description of the option')
54
+ property :default, [String, Integer, TrueClass, FalseClass], required: false,
55
+ desc: N_('Default value, from the smart proxy or a Foreman setting when one is set. ' \
56
+ 'If a Foreman setting is present and this is a sensitive type, the default value ' \
57
+ 'will read "[Use saved ecrypted default]".')
58
+ end
59
+ end
60
+ def task_options
61
+ super
62
+ end
63
+
64
+ api :POST, '/launch/task', N_('Launch a Bolt task on a smart proxy')
65
+ param :smart_proxy_id, Integer, required: true,
66
+ desc: N_('ID of the smart proxy that will execute the task')
67
+ param :task_name, String, required: true, desc: N_('Name of the Bolt task to run')
68
+ param :targets, String, required: true,
69
+ desc: N_('Comma-separated list of target hosts the task should run on')
70
+ param :parameters, Hash, required: false,
71
+ desc: N_('Task-specific parameters, keyed by parameter name')
72
+ param :options, Hash, required: false,
73
+ desc: N_('OpenBolt options (transport, user, run-as, etc.) as accepted by the bolt CLI')
74
+ returns code: 201, desc: N_('Job accepted by the smart proxy') do
75
+ property :job_id, String, desc: N_('Proxy-issued job ID, usable with the /jobs/:job_id endpoints')
76
+ property :kind, %w[task], desc: N_("Currently always 'task', and will include 'plan' at a later date.")
77
+ end
78
+ def launch_task
79
+ super
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'foreman/logging'
4
+ require 'proxy_api/openbolt'
5
+
6
+ module ForemanOpenbolt
7
+ module Common
8
+ extend ActiveSupport::Concern
9
+
10
+ ENCRYPTED_PLACEHOLDER = '[Use saved encrypted default]'
11
+ REDACTED_PLACEHOLDER = '*****'
12
+
13
+ class LaunchError < StandardError; end
14
+
15
+ # The caller sent the encrypted placeholder for an option with no saved setting.
16
+ class MissingEncryptedDefault < StandardError; end
17
+
18
+ # The proxy accepted the task but Foreman could not record or track it.
19
+ class PartialLaunchError < StandardError; end
20
+
21
+ included do
22
+ rescue_from ForemanOpenbolt::Common::LaunchError do |error|
23
+ logger.warn("OpenBolt #{openbolt_surface} launch failed: #{error.class}: #{error.message}")
24
+ render_json_error(error.message, :bad_request)
25
+ end
26
+
27
+ rescue_from ForemanOpenbolt::Common::PartialLaunchError do |error|
28
+ Foreman::Logging.exception("OpenBolt #{openbolt_surface} partial launch failure: #{error.message}", error)
29
+ render_json_error(error.message, :internal_server_error)
30
+ end
31
+
32
+ rescue_from ProxyAPI::ProxyException do |error|
33
+ Foreman::Logging.exception("OpenBolt #{openbolt_surface} proxy call failed", error)
34
+ render_json_error("Smart Proxy error: #{error.message}", :bad_gateway)
35
+ end
36
+
37
+ rescue_from ForemanOpenbolt::Common::MissingEncryptedDefault do |error|
38
+ logger.warn("OpenBolt #{openbolt_surface} missing encrypted default failure: #{error.message}")
39
+ render_json_error(error.message, :bad_request)
40
+ end
41
+ end
42
+
43
+ def openbolt_surface
44
+ self.class.name.start_with?('Api::') ? 'API' : 'UI'
45
+ end
46
+
47
+ def openbolt_settings
48
+ @openbolt_settings ||= Foreman.settings.category_settings(:openbolt).values
49
+ end
50
+
51
+ def merge_encrypted_defaults(options)
52
+ merged = options.dup
53
+ merged.each do |key, value|
54
+ next unless value == ENCRYPTED_PLACEHOLDER
55
+
56
+ saved = Setting["openbolt_#{key}"]
57
+ if saved.nil? || saved.to_s.empty?
58
+ raise MissingEncryptedDefault,
59
+ "No saved value for encrypted option '#{key}'. Configure it on the Foreman Settings page or provide a value."
60
+ end
61
+ merged[key] = saved
62
+ end
63
+ merged
64
+ end
65
+
66
+ def scrub_options_for_storage(options)
67
+ scrubbed = options.dup
68
+ openbolt_settings.select(&:encrypted?).each do |setting|
69
+ option_name = setting.name.sub(/^openbolt_/, '')
70
+ scrubbed[option_name] = REDACTED_PLACEHOLDER if scrubbed.key?(option_name)
71
+ end
72
+ scrubbed
73
+ end
74
+
75
+ def openbolt_options_with_defaults
76
+ options = @openbolt_api.openbolt_options
77
+
78
+ defaults = {}
79
+ openbolt_settings.each do |setting|
80
+ key = setting.name.sub(/^openbolt_/, '')
81
+ if setting.encrypted?
82
+ defaults[key] = ENCRYPTED_PLACEHOLDER unless setting.value.to_s.empty?
83
+ elsif !setting.value.to_s.empty?
84
+ defaults[key] = setting.value
85
+ end
86
+ end
87
+
88
+ result = {}
89
+ options.each do |name, meta|
90
+ result[name] = meta.dup
91
+ result[name]['default'] = defaults[name] if defaults.key?(name)
92
+ end
93
+ result
94
+ end
95
+
96
+ # before_action helpers shared by UI and API controllers
97
+ def load_smart_proxy
98
+ smart_proxy_id = params[:smart_proxy_id]
99
+ if smart_proxy_id.blank?
100
+ render_json_error('Smart Proxy ID is required', :bad_request)
101
+ return
102
+ end
103
+ @smart_proxy = SmartProxy.authorized(:view_smart_proxies).find_by(id: smart_proxy_id)
104
+ return if @smart_proxy
105
+ render_json_error("Smart Proxy with ID #{smart_proxy_id} not found or not authorized", :not_found)
106
+ end
107
+
108
+ def load_openbolt_api
109
+ return unless @smart_proxy
110
+ @openbolt_api = ProxyAPI::Openbolt.new(url: @smart_proxy.url)
111
+ rescue StandardError => e
112
+ Foreman::Logging.exception("load_openbolt_api for proxy #{@smart_proxy.name}", e)
113
+ render_json_error('Failed to connect to Smart Proxy', :bad_gateway)
114
+ end
115
+
116
+ # Matches Foreman's custom_error template so UI and API errors share one shape.
117
+ def render_json_error(message, status)
118
+ render json: { error: { message: message } }, status: status
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ForemanOpenbolt
4
+ module Jobs
5
+ extend ActiveSupport::Concern
6
+ include ForemanOpenbolt::Common
7
+
8
+ included do
9
+ before_action :load_task_job, only: [:job_status, :job_result]
10
+ end
11
+
12
+ def load_task_job
13
+ job_id = params[:job_id]
14
+ if job_id.blank?
15
+ render_json_error('Job ID is required', :bad_request)
16
+ return
17
+ end
18
+ @task_job = ForemanOpenbolt::TaskJob.find_by(job_id: job_id)
19
+ logger.debug { "TaskJob lookup #{job_id.inspect}: #{@task_job ? @task_job.status : 'not found'}" }
20
+ return if @task_job
21
+ render_json_error("Job #{job_id} not found", :not_found)
22
+ end
23
+
24
+ def task_job_status(task_job)
25
+ {
26
+ job_id: task_job.job_id,
27
+ kind: task_job.kind,
28
+ status: task_job.status,
29
+ submitted_at: task_job.submitted_at,
30
+ completed_at: task_job.completed_at,
31
+ duration: task_job.duration,
32
+ name: task_job.task_name,
33
+ description: task_job.task_description,
34
+ parameters: task_job.task_parameters,
35
+ targets: task_job.targets,
36
+ smart_proxy: {
37
+ id: task_job.smart_proxy_id,
38
+ name: task_job.smart_proxy&.name || '(unknown)',
39
+ },
40
+ }
41
+ end
42
+
43
+ def task_job_result(task_job)
44
+ {
45
+ kind: task_job.kind,
46
+ status: task_job.status,
47
+ command: task_job.command,
48
+ value: task_job.result,
49
+ log: task_job.log,
50
+ }
51
+ end
52
+
53
+ def paginated_task_jobs(per_page_param:, page:)
54
+ page = [page.to_i, 1].max
55
+ per_page = if per_page_param == 'all'
56
+ [ForemanOpenbolt::TaskJob.count, 1].max
57
+ elsif per_page_param.present?
58
+ per_page_param.to_i.clamp(1, 100)
59
+ else
60
+ 20
61
+ end
62
+ ForemanOpenbolt::TaskJob.includes(:smart_proxy).recent.paginate(page: page, per_page: per_page)
63
+ end
64
+
65
+ def job_status
66
+ render json: task_job_status(@task_job)
67
+ end
68
+
69
+ def job_result
70
+ render json: task_job_result(@task_job)
71
+ end
72
+
73
+ def jobs
74
+ paginated = paginated_task_jobs(per_page_param: params[:per_page], page: params[:page])
75
+
76
+ render json: {
77
+ total: paginated.total_entries,
78
+ page: paginated.current_page,
79
+ per_page: paginated.per_page,
80
+ results: paginated.map { |job| task_job_status(job) },
81
+ }
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'foreman/logging'
4
+
5
+ module ForemanOpenbolt
6
+ module Tasks
7
+ extend ActiveSupport::Concern
8
+ include ForemanOpenbolt::Common
9
+
10
+ included do
11
+ before_action :load_smart_proxy, only: [:tasks, :reload_tasks, :task_options, :launch_task]
12
+ before_action :load_openbolt_api, only: [:tasks, :reload_tasks, :task_options, :launch_task]
13
+ end
14
+
15
+ # Shared by the API and UI controllers. The API controller wraps each
16
+ # action with super so it can attach apipie docs.
17
+ def tasks
18
+ render json: @openbolt_api.tasks
19
+ end
20
+
21
+ def reload_tasks
22
+ render json: @openbolt_api.reload_tasks
23
+ end
24
+
25
+ def task_options
26
+ render json: openbolt_options_with_defaults
27
+ end
28
+
29
+ def launch_task
30
+ job_id = dispatch_task(
31
+ smart_proxy: @smart_proxy,
32
+ openbolt_api: @openbolt_api,
33
+ task_name: params[:task_name],
34
+ targets: params[:targets],
35
+ parameters: params[:parameters] || {},
36
+ options: params[:options] || {}
37
+ )
38
+ render json: { job_id: job_id, kind: 'task' }, status: :created
39
+ end
40
+
41
+ # Submits a task to the proxy, saves the TaskJob, and schedules polling.
42
+ # Returns the proxy-issued job id. Failures after the proxy accepts the
43
+ # launch raise PartialLaunchError. The proxy job keeps running and there
44
+ # is no proxy-side cancel hook right now, so we only surface the failure.
45
+ def dispatch_task(smart_proxy:, openbolt_api:, task_name:, targets:, parameters:, options:)
46
+ task_name = task_name.to_s.strip
47
+ targets = targets.to_s.strip
48
+ raise ForemanOpenbolt::Common::LaunchError, 'Task name and targets cannot be empty' if task_name.empty? || targets.empty?
49
+
50
+ merged_options = merge_encrypted_defaults(options)
51
+
52
+ logger.info { "Launching OpenBolt task '#{task_name}' on targets '#{targets}' via proxy #{smart_proxy.name}" }
53
+
54
+ response = openbolt_api.launch_task(
55
+ name: task_name,
56
+ targets: targets,
57
+ parameters: parameters,
58
+ options: merged_options
59
+ )
60
+ logger.debug { "Task execution response: #{response.inspect}" }
61
+
62
+ if response['error']
63
+ error_detail = response['error'].is_a?(Hash) ? response['error']['message'] : response['error']
64
+ raise ForemanOpenbolt::Common::LaunchError, "Task execution failed: #{error_detail}"
65
+ end
66
+ raise ForemanOpenbolt::Common::LaunchError, 'Task execution failed: No job ID returned' unless response['id']
67
+
68
+ # The proxy is now running the task so any failures below are partial state.
69
+ job_id = response['id']
70
+
71
+ task_job = begin
72
+ # Metadata is optional and a fetch failure should not abort a running job.
73
+ metadata = begin
74
+ fetched = openbolt_api.tasks[task_name]
75
+ if fetched.nil?
76
+ logger.warn(
77
+ "Proxy accepted launch of '#{task_name}' (job #{job_id}) but " \
78
+ "the task is not in the proxy's task list. Description will be empty."
79
+ )
80
+ end
81
+ fetched || {}
82
+ rescue StandardError => e
83
+ logger.warn(
84
+ "Could not fetch task metadata for #{task_name} after launching " \
85
+ "job #{job_id}: #{e.class}: #{e.message}. Proceeding without description."
86
+ )
87
+ {}
88
+ end
89
+
90
+ ForemanOpenbolt::TaskJob.create_from_execution!(
91
+ proxy: smart_proxy,
92
+ task_name: task_name,
93
+ task_description: metadata['description'] || '',
94
+ targets: targets.split(',').map(&:strip),
95
+ parameters: parameters,
96
+ options: scrub_options_for_storage(merged_options),
97
+ job_id: job_id
98
+ )
99
+ rescue StandardError => e
100
+ Foreman::Logging.exception(
101
+ "OpenBolt job #{job_id} launched on proxy #{smart_proxy.name} " \
102
+ "but the Foreman TaskJob row could not be created",
103
+ e
104
+ )
105
+ raise ForemanOpenbolt::Common::PartialLaunchError,
106
+ "Task launched on the proxy (job #{job_id}) but Foreman could not " \
107
+ "record it. The task will run on the proxy unmonitored. Error: #{e.message}"
108
+ end
109
+
110
+ begin
111
+ ForemanTasks.async_task(Actions::ForemanOpenbolt::PollTaskStatus,
112
+ job_id,
113
+ smart_proxy.id)
114
+ rescue StandardError => e
115
+ Foreman::Logging.exception(
116
+ "OpenBolt job #{job_id} launched on proxy #{smart_proxy.name} " \
117
+ "but PollTaskStatus could not be scheduled",
118
+ e
119
+ )
120
+ # Capture before update! flips the in-memory status.
121
+ previous_status = task_job.status
122
+ begin
123
+ task_job.update!(status: 'exception')
124
+ rescue StandardError => update_error
125
+ Foreman::Logging.exception(
126
+ "Could not mark TaskJob #{job_id} as exception after polling-" \
127
+ "schedule failure. Row will remain in '#{previous_status}' state.",
128
+ update_error
129
+ )
130
+ end
131
+ raise ForemanOpenbolt::Common::PartialLaunchError,
132
+ "Task launched on the proxy (job #{job_id}) but background polling " \
133
+ "could not be scheduled. The task will run on the proxy without " \
134
+ "status updates in Foreman. Error: #{e.message}"
135
+ end
136
+
137
+ job_id
138
+ end
139
+ end
140
+ end