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.
- checksums.yaml +4 -4
- data/README.md +1 -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/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 +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7444805dbd4902da2b3b411388c28968cc4a893d00d244caacd8ed083cca78e
|
|
4
|
+
data.tar.gz: 90aab4b505c4347d212324094d7d5d73b8a1dcc9148eafa40a2be77e8fdee083
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|